From 2bf19ec59f59b99439feec51e9065a1a3fccce48 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 25 Oct 2026 14:12:08 +0100 Subject: [PATCH 01/27] Add manifest console pool selection (#946) Add a default-false `console` flag to actions and targets, and reject it on dependency-only entries. Preserve validation diagnostics while lowering manifests into the IR. --- src/ast/mod.rs | 12 ++++++ src/ast/target.rs | 14 ++++++- src/ir/from_manifest/mod.rs | 6 +-- src/manifest/render/command_list_tests.rs | 1 + src/manifest/render/script_tests.rs | 1 + src/manifest/render/tests.rs | 2 + src/manifest/render/tests/query.rs | 1 + src/runner/help/graph_json/tests.rs | 1 + src/runner/help/tests.rs | 1 + tests/ast_tests.rs | 3 ++ tests/ast_tests/actions.rs | 26 +++++++++++++ tests/ast_tests/console.rs | 46 +++++++++++++++++++++++ tests/ast_tests/dependency_only.rs | 37 ++++++++++++++++++ tests/ast_tests/parsing.rs | 1 - 14 files changed, 146 insertions(+), 6 deletions(-) create mode 100644 tests/ast_tests/console.rs diff --git a/src/ast/mod.rs b/src/ast/mod.rs index a1194f4e3..34aa8d7e2 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -186,6 +186,10 @@ pub enum Recipe { /// Preserve the established diagnostic for entries without a recipe or deps. pub(crate) const MISSING_RECIPE_ERROR: &str = "missing one of command, script, or rule"; +/// Explain why dependency-only entries cannot request the console pool. +pub(crate) const CONSOLE_DEPENDENCY_ONLY_ERROR: &str = + "console cannot be enabled for dependency-only entries"; + /// Neutral schema error for modes that do not accompany a command list. pub(crate) const COMMAND_ISOLATION_RECIPE_ERROR: &str = "command_isolation requires a command-list recipe"; @@ -307,6 +311,14 @@ impl NetsukeManifest { { return Err(MISSING_RECIPE_ERROR); } + if self + .actions + .iter() + .chain(&self.targets) + .any(|target| target.console && target.recipe.is_dependency_only()) + { + return Err(CONSOLE_DEPENDENCY_ONLY_ERROR); + } Ok(()) } } diff --git a/src/ast/target.rs b/src/ast/target.rs index 15aeb7901..c31bec2ee 100644 --- a/src/ast/target.rs +++ b/src/ast/target.rs @@ -8,7 +8,13 @@ use super::{DependencyOrder, Recipe, StringOrList, Vars}; /// /// Targets describe the files produced by a rule and their dependencies. /// `phony` targets are always considered out of date, while `always` targets are -/// regenerated even if their inputs are unchanged. +/// regenerated even if their inputs are unchanged. `console` asks Ninja to run +/// an executable edge in its serial console pool, which can make long-running +/// output visible sooner at the cost of serializing other console edges. +#[expect( + clippy::struct_excessive_bools, + reason = "manifest controls are independent and may be combined" +)] #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub struct Target { @@ -46,6 +52,12 @@ pub struct Target { #[serde(default)] pub always: bool, + /// Run an executable action or target in Ninja's serial console pool. + /// + /// Defaults to `false`. Dependency-only entries cannot use this setting. + #[serde(default)] + pub console: bool, + /// Internal discovery metadata marking a target with an unevaluated `when`. #[serde(default)] pub conditional: bool, diff --git a/src/ir/from_manifest/mod.rs b/src/ir/from_manifest/mod.rs index 061ac6551..4b2337b88 100644 --- a/src/ir/from_manifest/mod.rs +++ b/src/ir/from_manifest/mod.rs @@ -10,7 +10,7 @@ use std::sync::Arc; use camino::Utf8PathBuf; -use crate::ast::{MISSING_RECIPE_ERROR, NetsukeManifest, Recipe, Rule}; +use crate::ast::{NetsukeManifest, Recipe, Rule}; use crate::localization::{self, keys}; use crate::recipe_shell::RecipeShell; @@ -66,9 +66,7 @@ impl BuildGraph { ) -> Result { manifest .validate_recipes() - .map_err(|_| IrGenError::InvalidManifest { - message: MISSING_RECIPE_ERROR, - })?; + .map_err(|message| IrGenError::InvalidManifest { message })?; let mut graph = Self::default(); let mut rule_map = IrHashMap::>::default(); diff --git a/src/manifest/render/command_list_tests.rs b/src/manifest/render/command_list_tests.rs index 05a46c5c2..8541a179c 100644 --- a/src/manifest/render/command_list_tests.rs +++ b/src/manifest/render/command_list_tests.rs @@ -94,6 +94,7 @@ fn target_recipe_context_reserves_ins_and_outs_placeholders() -> Result<()> { vars: target_vars, phony: false, always: false, + console: false, conditional: false, description: None, }], diff --git a/src/manifest/render/script_tests.rs b/src/manifest/render/script_tests.rs index 46008e50c..0196a2de4 100644 --- a/src/manifest/render/script_tests.rs +++ b/src/manifest/render/script_tests.rs @@ -30,6 +30,7 @@ fn script_recipe_context_reserves_ins_and_outs_placeholders() -> Result<()> { vars: target_vars, phony: false, always: false, + console: false, conditional: false, description: None, }], diff --git a/src/manifest/render/tests.rs b/src/manifest/render/tests.rs index 570dfecd1..6aa390f06 100644 --- a/src/manifest/render/tests.rs +++ b/src/manifest/render/tests.rs @@ -28,6 +28,7 @@ fn sample_manifest() -> Result { vars: target_vars, phony: false, always: false, + console: false, conditional: false, description: Some("{{ message }}".into()), }; @@ -220,6 +221,7 @@ fn render_manifest_renders_script_and_rule_ref_recipes() -> Result<()> { vars: target_vars, phony: false, always: false, + console: false, conditional: false, description: None, }; diff --git a/src/manifest/render/tests/query.rs b/src/manifest/render/tests/query.rs index c488b2beb..81c1c346f 100644 --- a/src/manifest/render/tests/query.rs +++ b/src/manifest/render/tests/query.rs @@ -38,6 +38,7 @@ fn manifest_with_build_only_recipe_helper() -> Result { vars: action_vars, phony: true, always: false, + console: false, conditional: false, description: Some("{{ description }}".into()), }], diff --git a/src/runner/help/graph_json/tests.rs b/src/runner/help/graph_json/tests.rs index 1aa966dbe..8b42208dc 100644 --- a/src/runner/help/graph_json/tests.rs +++ b/src/runner/help/graph_json/tests.rs @@ -177,6 +177,7 @@ fn target_strategy() -> impl Strategy { vars: HashMap::default(), phony, always, + console: false, conditional, description, }, diff --git a/src/runner/help/tests.rs b/src/runner/help/tests.rs index 9fa4e9e98..f1a775ba8 100644 --- a/src/runner/help/tests.rs +++ b/src/runner/help/tests.rs @@ -313,6 +313,7 @@ fn catalogue_target( vars: crate::ast::Vars::default(), phony, always: false, + console: false, conditional, description, } diff --git a/tests/ast_tests.rs b/tests/ast_tests.rs index ff85e6e54..72db06198 100644 --- a/tests/ast_tests.rs +++ b/tests/ast_tests.rs @@ -6,6 +6,9 @@ #[path = "ast_tests/actions.rs"] mod actions; +#[path = "ast_tests/console.rs"] +mod console; + #[path = "ast_tests/dependency_order.rs"] mod dependency_order; diff --git a/tests/ast_tests/actions.rs b/tests/ast_tests/actions.rs index 96edb47ab..bac80a30f 100644 --- a/tests/ast_tests/actions.rs +++ b/tests/ast_tests/actions.rs @@ -18,6 +18,7 @@ use super::support::parse_manifest; command: "true" "#, true, + false, false )] #[case::explicit_phony_false( @@ -32,6 +33,22 @@ use super::support::parse_manifest; command: "true" "#, true, + false, + false +)] +#[case::explicit_console_false( + r#" + netsuke_version: "1.0.0" + actions: + - name: setup + command: "echo hi" + console: false + targets: + - name: done + command: "true" +"#, + true, + false, false )] #[case::explicit_always_true( @@ -41,10 +58,12 @@ use super::support::parse_manifest; - name: setup command: "echo hi" always: true + console: true targets: - name: done command: "true" "#, + true, true, true )] @@ -52,6 +71,7 @@ fn actions_behaviour( #[case] yaml: &str, #[case] expected_phony: bool, #[case] expected_always: bool, + #[case] expected_console: bool, ) -> Result<()> { let manifest = parse_manifest(yaml)?; let action = manifest.actions.first().context("expected action entry")?; @@ -67,6 +87,12 @@ fn actions_behaviour( action.always, expected_always ); + ensure!( + action.console == expected_console, + "unexpected console flag: got {}, expected {}", + action.console, + expected_console + ); Ok(()) } diff --git a/tests/ast_tests/console.rs b/tests/ast_tests/console.rs new file mode 100644 index 000000000..581c623d3 --- /dev/null +++ b/tests/ast_tests/console.rs @@ -0,0 +1,46 @@ +//! Tests for the opt-in Ninja console scheduling field. + +use anyhow::{Context, Result, ensure}; +use rstest::rstest; + +use super::support::parse_manifest; + +/// Default target console scheduling to false and accept either explicit value. +#[rstest] +#[case::omitted("", false)] +#[case::explicit_false("console: false", false)] +#[case::explicit_true("console: true", true)] +fn target_console_defaults_and_overrides( + #[case] console_field: &str, + #[case] expected_console: bool, +) -> Result<()> { + let yaml = format!( + "netsuke_version: '1.0.0'\ntargets:\n - name: lint\n command: 'cargo clippy'\n {console_field}\n" + ); + let manifest = parse_manifest(&yaml)?; + let target = manifest.targets.first().context("expected target entry")?; + ensure!( + target.console == expected_console, + "unexpected console flag: got {}, expected {}", + target.console, + expected_console + ); + Ok(()) +} + +/// Keep contention fields unknown until RFC 0024 defines a manifest schema. +#[test] +fn contention_remains_an_unknown_manifest_field() { + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: lint + command: "cargo clippy" + console: true + contention: developer-gates + "#; + assert!( + parse_manifest(yaml).is_err(), + "contention should remain an unknown manifest field" + ); +} diff --git a/tests/ast_tests/dependency_only.rs b/tests/ast_tests/dependency_only.rs index b126f2fc2..0030b7aa1 100644 --- a/tests/ast_tests/dependency_only.rs +++ b/tests/ast_tests/dependency_only.rs @@ -225,3 +225,40 @@ fn direct_ast_deserialisation_is_validated_before_ir_lowering() -> Result<()> { ); Ok(()) } + +/// Reject console scheduling on dependency-only entries during parsing. +#[rstest] +#[case::action("actions:\n - name: all\n deps: [lint]\n console: true\ntargets: []\n")] +#[case::target("actions: []\ntargets:\n - name: release\n deps: [build]\n console: true\n")] +fn dependency_only_console_entries_are_rejected(#[case] sections: &str) -> Result<()> { + let yaml = format!("netsuke_version: '1.0.0'\n{sections}"); + let error = + parse_manifest(&yaml).expect_err("dependency-only entries cannot use the console pool"); + let chain = display_error_chain(error.as_ref()); + ensure!( + chain.contains("console cannot be enabled for dependency-only entries"), + "unexpected console dependency-only diagnostic: {chain}" + ); + Ok(()) +} + +/// Preserve the console-specific validation diagnostic during direct IR lowering. +#[test] +fn direct_ir_lowering_preserves_console_dependency_only_diagnostic() -> Result<()> { + let yaml = concat!( + "netsuke_version: '1.0.0'\n", + "targets:\n", + " - name: release\n", + " deps: [build]\n", + " console: true\n", + ); + let manifest: NetsukeManifest = + serde_saphyr::from_str(yaml).context("directly deserialize the manifest")?; + let error = BuildGraph::from_manifest(&manifest) + .expect_err("console scheduling on an aggregate should fail IR lowering"); + ensure!( + error.to_string() == "console cannot be enabled for dependency-only entries", + "direct AST lowering should preserve the console diagnostic: {error}" + ); + Ok(()) +} diff --git a/tests/ast_tests/parsing.rs b/tests/ast_tests/parsing.rs index 9450b59fb..0b12fd1c5 100644 --- a/tests/ast_tests/parsing.rs +++ b/tests/ast_tests/parsing.rs @@ -45,7 +45,6 @@ targets: } Ok(()) } - #[test] fn missing_required_fields() -> Result<()> { { From e7263282037631f0f4d260808f5d43a763118a6b Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 25 Oct 2026 16:21:44 +0100 Subject: [PATCH 02/27] Emit console pool bindings in Ninja (#946) Carry the manifest opt-in through BuildEdge and emit an edge-level Ninja binding while preserving action identity and shared rules. Validate console scheduling in direct and dyndep generation, and cover streaming, exclusivity, localization, documentation, and public API compatibility. --- ...elect-ninja-console-pool-per-build-edge.md | 65 +++ docs/contents.md | 3 + docs/developers-guide.md | 11 + docs/netsuke-design.md | 49 ++- .../rfcs/rfc-0024-named-contention-classes.md | 28 +- docs/roadmap-progressive-enhancement.md | 9 +- docs/users-guide.md | 29 ++ locales/ar/messages.ftl | 2 + locales/cs/messages.ftl | 2 + locales/cy/messages.ftl | 2 + locales/da/messages.ftl | 2 + locales/de/messages.ftl | 2 + locales/el/messages.ftl | 2 + locales/en-GB/messages.ftl | 2 + locales/en-US/messages.ftl | 2 + locales/es-419/messages.ftl | 2 + locales/es-ES/messages.ftl | 2 + locales/fa/messages.ftl | 2 + locales/fi/messages.ftl | 2 + locales/fr/messages.ftl | 2 + locales/gd/messages.ftl | 2 + locales/he/messages.ftl | 2 + locales/hi/messages.ftl | 2 + locales/hu/messages.ftl | 2 + locales/id/messages.ftl | 2 + locales/it/messages.ftl | 2 + locales/ja/messages.ftl | 2 + locales/ko/messages.ftl | 2 + locales/nb/messages.ftl | 2 + locales/nl/messages.ftl | 2 + locales/pl/messages.ftl | 2 + locales/pt-BR/messages.ftl | 2 + locales/pt-PT/messages.ftl | 2 + locales/ro/messages.ftl | 2 + locales/ru/messages.ftl | 2 + locales/sv/messages.ftl | 2 + locales/th/messages.ftl | 2 + locales/tr/messages.ftl | 2 + locales/uk/messages.ftl | 2 + locales/vi/messages.ftl | 2 + locales/zh-Hans/messages.ftl | 2 + locales/zh-Hant/messages.ftl | 2 + public_api_fixture/src/lib.rs | 2 + src/graph_view/tests/support.rs | 1 + src/ir/cycle/property_tests/analyse_tests.rs | 1 + .../property_tests/issue322_property_tests.rs | 1 + src/ir/cycle/property_tests/mod.rs | 1 + src/ir/cycle/tests.rs | 1 + src/ir/cycle/verification.rs | 1 + src/ir/from_manifest/mod.rs | 1 + src/ir/graph/mod.rs | 5 + src/localization/keys.rs | 2 + src/ninja_gen/display_edge.rs | 3 + src/ninja_gen/dyndep/mod.rs | 16 +- src/ninja_gen/dyndep/tests/console.rs | 47 +++ src/ninja_gen/dyndep/tests/dependency_only.rs | 1 + src/ninja_gen/dyndep/tests/mod.rs | 3 +- src/ninja_gen/error.rs | 18 + src/ninja_gen/mod.rs | 14 +- .../property_tests/dependency_only.rs | 1 + src/ninja_gen/property_tests/mod.rs | 2 + src/ninja_gen/property_tests/ninja_oracle.rs | 1 + src/ninja_gen/tests/console.rs | 109 +++++ src/ninja_gen/tests/mod.rs | 11 +- src/ninja_gen/tests/shell_command.rs | 10 + src/ninja_gen/validation.rs | 183 ++++++++- src/runner/dyndep/generation_telemetry.rs | 2 + src/runner/tests/mod.rs | 1 + tests/bdd/steps/manifest/actions.rs | 26 ++ tests/bdd/steps/manifest/helpers.rs | 15 + tests/bdd/steps/manifest/mod.rs | 16 + tests/bdd/steps/manifest/targets.rs | 26 +- tests/bdd/steps/ninja.rs | 48 ++- tests/console_pool_runtime_tests.rs | 384 ++++++++++++++++++ tests/data/console.yml | 16 + tests/documentation_examples_tests.rs | 22 + tests/features/manifest.feature | 8 + tests/features/ninja.feature | 12 + tests/ir_tests.rs | 3 + tests/ninja_dollar_escaping_tests.rs | 1 + ...inja_gen_command_list_integration_tests.rs | 1 + ..._command_list_process_integration_tests.rs | 1 + tests/ninja_gen_integration_tests.rs | 7 + tests/ninja_gen_unit_tests.rs | 8 + tests/ninja_snapshot_tests.rs | 42 +- tests/serial_dependency_runtime_tests.rs | 1 + ...napshot_tests__console_manifest_ninja.snap | 25 ++ tests/ui/build_edge_console_compile_pass.rs | 20 + 88 files changed, 1336 insertions(+), 48 deletions(-) create mode 100644 docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md create mode 100644 src/ninja_gen/dyndep/tests/console.rs create mode 100644 src/ninja_gen/tests/console.rs create mode 100644 src/ninja_gen/tests/shell_command.rs create mode 100644 tests/bdd/steps/manifest/actions.rs create mode 100644 tests/console_pool_runtime_tests.rs create mode 100644 tests/data/console.yml create mode 100644 tests/snapshots/ninja/ninja_snapshot_tests__console_manifest_ninja.snap create mode 100644 tests/ui/build_edge_console_compile_pass.rs diff --git a/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md b/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md new file mode 100644 index 000000000..b809dc891 --- /dev/null +++ b/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md @@ -0,0 +1,65 @@ +# Architectural decision record (ADR) 0060: Select Ninja's console pool per build edge + +## Status + +Accepted. Actions and targets may set `console: true`; Netsuke carries that +choice on `BuildEdge` and writes `pool = console` on the generated build edge. +The option defaults to `false` and does not change `Action` identity. + +## Date + +2026-10-25. + +## Context and problem statement + +Ninja buffers a command's output until it exits unless the edge uses Ninja's +special `console` pool. A manifest currently cannot select that pool. Long +developer gates can therefore appear idle while they run, even when their +commands are waiting on a lock or making progress. + +The IR shares actions when their resolved recipes and file sets match. Making +console selection part of `Action` would make a scheduling choice affect action +identity and could prevent otherwise identical edges from sharing an action. +The pool is also an edge-level Ninja binding, so the graph edge is its natural +owner. + +## Decision drivers + +- Preserve action hashing and rule sharing when only one edge needs streamed + output. +- Keep scheduling validation common to direct generation and dyndep bundles. +- Reject combinations that Ninja cannot represent without silently dropping a + requested guarantee. +- Explain the serialization and output-forwarding limits to manifest authors. + +## Decision outcome + +- Add a default-false `console` Boolean to manifest actions and targets, and + carry it as `BuildEdge.console`. +- Emit an edge-level `pool = console` binding only for selected user edges. + Synthetic serial gates and dyndep sidecars do not inherit the binding. +- Keep `Action.pool` and action hashing unchanged. A pre-existing + `Action.pool: console` is accepted as redundant; a different custom pool + conflicts with console selection and is rejected by the generator. +- Reject console selection on dependency-only aggregates. Reject console plus + a named contention class while continuing to allow console selection by + itself. +- Validate these rules before both direct Ninja generation and dyndep-bundle + generation, preserving the existing missing-action diagnostic path. + +Ninja runs console-pool edges one at a time in one invocation. Output from +other edges is held while a console edge runs. Netsuke forwards Ninja output as +it reads it outside JSON mode, but it does not allocate a PTY; recipes see a +pipe and their own buffering can still delay visible output. + +## Consequences + +Manifest authors can opt long or interactive gates into prompt output without +changing reusable rules or action identity. They also accept reduced edge +concurrency while any console edge is running. The option cannot provide +interactive terminal semantics, and it does not bypass buffering inside a +recipe. + +The contention-class RFC must reject only the combination of console and a +contention class, not console selection in general. Generator validation also +guards against incompatible custom pools supplied through existing IR callers. diff --git a/docs/contents.md b/docs/contents.md index eb78b24ca..91c5dd80e 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -318,6 +318,9 @@ operator, user, and contributor references are easier to find. moving package verification into `make test-package-verify`. - [ADR-0060](adrs/adr-0060-readable-inline-recipe-progress.md): Bounded, localized Ninja progress labels for inline recipe edges. +- [ADR-0061](adrs/adr-0061-select-ninja-console-pool-per-build-edge.md): + Select Ninja's console pool on individual build edges while preserving action + identity and rejecting incompatible scheduling annotations. - [ADR-0068](adrs/adr-0068-detect-cycles-across-all-prerequisite-kinds.md): Include every prerequisite kind in whole-manifest cycle detection while keeping output aliases and serial scheduling semantics explicit. diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 2d6f7dbf7..90b9ba257 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -6502,6 +6502,17 @@ variants, and `parallel` remains the default. The ordering policy applies only to a manifest `deps` list; never infer it from the number or shape of graph edges, and do not apply it to inputs or order-only dependencies. +The manifest `console` Boolean lowers to `BuildEdge.console`. It is scheduling +metadata owned by the edge because two targets can share one action and rule +while only one target needs streamed output. `Action.pool` remains unchanged, +so console selection does not alter action identity or action deduplication. +`src/ninja_gen/display_edge.rs` emits `pool = console` on that edge, and shared +console-scheduling validation runs before both direct Ninja generation and +dyndep-bundle generation. That validation rejects dependency-only edges and +custom action pools that conflict with `console`; it accepts an existing +`Action.pool` value of `console`. Synthetic serial gates and dyndep sidecars +never inherit the binding. + `src/ir/cycle/detector.rs::CycleDetector::visit_known_edge` traverses `inputs`, `implicit_deps`, and `order_only_deps` when detecting cycles. Order-only dependencies do not trigger a rebuild when they change, but they still have to diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 8cd6b2064..27fe83764 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -277,6 +277,7 @@ erDiagram string description bool phony bool always + bool console } RECIPE { StringOrList command @@ -514,6 +515,15 @@ The cleaner model is: - `always`: When set to `true`, the target runs on every invocation regardless of timestamps or dependencies. The default value is `false`. +- `console`: When set to `true`, Netsuke assigns this action or target's build + edge to Ninja's special `console` pool so command output can be forwarded as + it is produced. The default is `false`. Dependency-only aggregates cannot use + it, and it cannot be combined with a contention class or a custom action pool + other than `console`. Console selection is edge metadata; it does not change + action identity or prevent otherwise identical actions from sharing a rule. + Ninja runs console-pool edges one at a time. See + [ADR-0061](adrs/adr-0061-select-ninja-console-pool-per-build-edge.md). + ### 2.5 Generated Targets and Actions with `foreach` #### Manifest resource budget @@ -1058,6 +1068,10 @@ pub struct Target { /// Run this target on every invocation regardless of timestamps. #[serde(default)] pub always: bool, + + /// Select Ninja's special console pool for this target's build edge. + #[serde(default)] + pub console: bool, } /// FUTURE: Environment variable operations applied to a recipe invocation. @@ -1141,6 +1155,7 @@ let ast = NetsukeManifest { description: None, phony: false, always: false, + console: false, }], defaults: vec![], }; @@ -2425,7 +2440,8 @@ pub enum EnvBinding { /// Represents a single build statement, analogous to a Ninja 'build' edge. /// It connects a set of inputs to a set of outputs via an Action. The `phony` /// and `always` flags control execution when outputs already exist or when -/// timestamps would normally skip the step. +/// timestamps would normally skip the step. `console` selects Ninja's special +/// console pool for this edge without changing the referenced Action. pub struct BuildEdge { /// The unique identifier of the Action used for this edge. pub action_id: String, @@ -2456,6 +2472,9 @@ pub struct BuildEdge { /// Run this edge on every invocation regardless of timestamps. pub always: bool, + + /// Run this edge in Ninja's special console pool. + pub console: bool, } ``` @@ -2491,6 +2510,7 @@ classDiagram +Vec order_only_deps +bool phony +bool always + +bool console } class EdgeId { +usize index @@ -2550,15 +2570,17 @@ This transformation involves several steps: fully resolved recipe and file set, so identical rule templates yield distinct actions when their paths differ. Create a corresponding `ir::BuildEdge` linking the target to the action identifier and transfer the - `phony` and `always` flags. `sources` are lowered into the edge's explicit - input list so recipe interpolation and Ninja `$in` see only material inputs. - `deps` are lowered into a separate `implicit_deps` list, which maps to - Ninja's implicit dependency syntax (`|`) so Ninja orders and rebuilds them - without exposing them as recipe arguments; `order_only_deps` remains - separate and maps to Ninja's `||` class. The edge enters the arena once; - every explicit output is then indexed to its stable `EdgeId`. Duplicate - outputs are rejected before this mutation, so each alias resolves to one - canonical edge without copying its output vector. + `phony`, `always`, and `console` flags. Console remains edge-level + scheduling metadata, so it does not change action hashing or rule sharing. + `sources` are lowered into the edge's explicit input list so recipe + interpolation and Ninja `$in` see only material inputs. `deps` are lowered + into a separate `implicit_deps` list, which maps to Ninja's implicit + dependency syntax (`|`) so Ninja orders and rebuilds them without exposing + them as recipe arguments; `order_only_deps` remains separate and maps to + Ninja's `||` class. The edge enters the arena once; every explicit output is + then indexed to its stable `EdgeId`. Duplicate outputs are rejected before + this mutation, so each alias resolves to one canonical edge without copying + its output vector. FUTURE: @@ -2857,9 +2879,10 @@ conceptual model while remaining backend-agnostic. `BuildGraph` collects actions in a hash map, canonical edges in an insertion-ordered arena, and output aliases in a `Utf8PathBuf` to `EdgeId` hash map. Actions hold the parsed `Recipe` and optional execution metadata. `BuildEdge` connects inputs to -outputs using an action identifier and carries the `phony` and `always` flags -verbatim from the manifest. No Ninja-specific placeholders are stored in the IR -to keep the representation portable. +outputs using an action identifier and carries the `phony`, `always`, and +`console` flags verbatim from the manifest. `console` is edge-level scheduling +metadata and does not affect action identity. No Ninja-specific placeholders +are stored in the IR to keep the representation portable. - Actions are deduplicated using a SHA-256 hash of a canonical JSON serialization of their recipe, inputs, and outputs. Because commands embed diff --git a/docs/rfcs/rfc-0024-named-contention-classes.md b/docs/rfcs/rfc-0024-named-contention-classes.md index 2f3e8cee5..f0e36cdc5 100644 --- a/docs/rfcs/rfc-0024-named-contention-classes.md +++ b/docs/rfcs/rfc-0024-named-contention-classes.md @@ -81,7 +81,11 @@ only, with an explicit unsupported-field diagnostic on rules. Dependency-only aggregates cannot hold a slot because they execute no command. Reject aggregate annotations with a remedy naming the executable children; do not infer recursive inheritance across their dependency closure. An annotation -on a consumer neither constrains its prerequisites nor changes their order. +on a consumer neither constrains its prerequisites nor changes their order. The +independent `console: true` action or target option selects Ninja's special +console pool for that edge. It remains valid on its own, but a console edge +cannot also request a contention class. Reject only that combination; do not +reject console selection generally. ## 5. Scheduling and lowering @@ -103,8 +107,11 @@ Unrelated classes cannot express overlapping exclusion sets in this version; combining their names into a new class would not enforce the original limits. Ninja's special `console` pool cannot simultaneously be combined with a custom -pool on the same edge. Reject a request that requires both guarantees. Do not -silently discard either console behaviour or contention limits. +pool on the same edge. When a manifest edge opts into `console`, generation +rejects an existing custom `Action.pool` value other than `console`; an existing +`Action.pool: console` is redundant and accepted. A console edge with a +resolved contention class is rejected as well. Console selection remains +available without contention, and neither behaviour is silently discarded. Serialize resolved class definitions and references in generated plans and include scheduling metadata in the existing graph/plan identity. Reject unknown @@ -156,8 +163,9 @@ actions, but may not infer such a requirement from a command containing the word Compare unannotated graph and Ninja output against existing fixtures. Add a small legacy-command example before any larger structured-runner canary. Test -unknown classes, capacity bounds, conflicting definitions, console conflicts, -aggregates, namespace privacy, and persisted-plan compatibility. +unknown classes, capacity bounds, conflicting definitions, the combination of +console selection and a contention class, custom-pool conflicts, aggregates, +namespace privacy, and persisted-plan compatibility. Use controlled child processes and a shared test journal to measure live concurrency. Under global `-j` greater than one, assert that a capacity-one @@ -178,10 +186,12 @@ scheduling. A generalized multi-resource scheduler would duplicate Ninja and create a much larger correctness burden. Mandatory resource budgets would undermine the shallow end without necessarily controlling subprocesses. -Ratify the operator ceiling field, class-name lowering, console interaction, -and any rule-default semantics before implementing the public grammar. Reserve -multi-resource allocation and cross-host scheduling for separate evidence-led -proposals. Ordinary native compilation limits must not depend on those features. +Ratify the operator ceiling field, class-name lowering, and any rule-default +semantics before implementing the public grammar. Console selection is an +independent edge option; only its combination with a contention class is +prohibited. Reserve multi-resource allocation and cross-host scheduling for +separate evidence-led proposals. Ordinary native compilation limits must not +depend on those features. ## 10. Recommendation diff --git a/docs/roadmap-progressive-enhancement.md b/docs/roadmap-progressive-enhancement.md index 15e754e55..3aa286083 100644 --- a/docs/roadmap-progressive-enhancement.md +++ b/docs/roadmap-progressive-enhancement.md @@ -189,7 +189,9 @@ pool machinery, with no hidden overlapping-resource scheduler. - [ ] 22.1.2. Lower resolved classes into Ninja pool definitions. Requires 22.1.1. - [ ] Reuse `Action.pool`, emit stable bounded names, and preserve dependency - and dyndep semantics. Reject console/custom-pool conflicts. + and dyndep semantics. Keep `console: true` as independent edge-level + selection; reject only console plus contention and reject conflicting + custom action pools during generation. - [ ] Include complete action sequences and persisted scheduling metadata; integrate with 12.3.1 rather than reimplementing the codec. - [ ] Property-test declaration-order independence and unannotated-output @@ -203,8 +205,9 @@ the guarantee and exposes its invocation-only boundary. - [ ] 22.2.1. Add controlled-process concurrency tests. Requires 22.1.2. - [ ] Assert capacities one and two under larger global `-j`, and show an unrelated action can run while a class is occupied. - - [ ] Exercise multi-command edges, failure, cancellation, and console - rejection without relying solely on sleeps. + - [ ] Exercise multi-command edges, failure, cancellation, and rejection of + console plus contention without relying solely on sleeps. Prove console + output forwarding and console-pool exclusivity with controlled handshakes. - [ ] Demonstrate that separate Ninja invocations do not share the limit. See RFC 0024 sections 6 and 8. - [ ] 22.2.2. Publish class inspection and the native-build canary. Requires diff --git a/docs/users-guide.md b/docs/users-guide.md index a1094228c..6a0f8467e 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -609,6 +609,10 @@ A target supports these fields: and `glob` restriction above applies here too. - `phony`: marks a logical target that does not represent a file. - `always`: forces the recipe to run whenever the target is requested. +- `console`: selects Ninja's serial console pool for this executable action or + target. It defaults to `false` and is unavailable to dependency-only + aggregates. Netsuke rejects a conflicting custom action pool; an action pool + already named `console` is redundant and accepted. - `description`: an optional human-readable summary of the public operation the target performs. It is discovery metadata shown by `netsuke help targets`; it never replaces a referenced rule's `description` @@ -617,6 +621,31 @@ A target supports these fields: `name`, `sources`, `deps`, and `order_only_deps` accept either one string or a list of strings. +### Stream long-running output with the console pool + +Set `console: true` on a long-running or interactive action or target, such as +`lint` or `test`, when its output should appear as it runs: + + + +```yaml +netsuke_version: "1.0.0" +targets: + - name: lint + command: cargo clippy --all-targets + console: true +``` + +Ninja's console pool is available from Ninja 1.5. It runs console-pool edges +one at a time in one invocation. While one such edge runs, Ninja buffers +progress and output from other edges, so enabling the field can reduce parallel +build throughput. + +Outside JSON mode, Netsuke forwards Ninja output as it reads it. Netsuke does +not provide a pseudo-terminal: the recipe sees a pipe, and visibility still +depends on the recipe flushing its own output. JSON mode suppresses subprocess +output so that diagnostics remain parseable. + Netsuke quotes paths inserted through `{{ ins }}` and `{{ outs }}`. Other Jinja values render as ordinary command text and are not automatically shell-quoted. Use the `shell_quote` filter to encode one value as a single shell word, and diff --git a/locales/ar/messages.ftl b/locales/ar/messages.ftl index 27c6ea17c..27033c10b 100644 --- a/locales/ar/messages.ftl +++ b/locales/ar/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = إقحام غير صالح داخل الأمر: { $snippet # أخطاء توليد ملفات Ninja. ninja_gen.missing_action = الإجراء «{ $id }» الذي تشير إليه حافة بناء مفقود. +ninja_gen.console_dependency_only = ‏لا يمكن لحافة البناء «{ $output }» اختيار مجموعة وحدة التحكم لأن إجراءها يقتصر على التبعيات. +ninja_gen.console_pool_conflict = ‏تختار حافة البناء «{ $output }» مجموعة وحدة التحكم، لكن إجراءها يطلب مجموعة مخصصة «{ $pool }». ninja_gen.format = تعذّر تنسيق مخرجات ملف بيانات Ninja. ninja_gen.dyndep_files_required = ‏ يتطلّب هذا البناء حزمة Ninja مُنشأة؛ استخدم `netsuke build` أو `netsuke clean` أو `netsuke generate` لتجهيز ملفات dyndep. ninja_gen.reserved_output_path = ‏ المسار '{ $path }' محجوز لحالة تبعيات Netsuke التسلسلية. diff --git a/locales/cs/messages.ftl b/locales/cs/messages.ftl index dab5e7e39..e2fffe2d9 100644 --- a/locales/cs/messages.ftl +++ b/locales/cs/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = Neplatné vložení v příkazu: { $snippet }. # Chyby při generování souborů Ninja. ninja_gen.missing_action = Chybí akce „{ $id }“, na kterou odkazuje hrana sestavení. +ninja_gen.console_dependency_only = Sestavovací hrana „{ $output }“ nemůže vybrat fond konzole, protože její akce slouží pouze jako závislost. +ninja_gen.console_pool_conflict = Sestavovací hrana „{ $output }“ vybírá fond konzole, ale její akce požaduje vlastní fond „{ $pool }“. ninja_gen.format = Výstup manifestu Ninja se nepodařilo naformátovat. ninja_gen.dyndep_files_required = Toto sestavení vyžaduje vygenerovaný balíček Ninja; použijte `netsuke build`, `netsuke clean` nebo `netsuke generate`, aby se soubory dyndep materializovaly. ninja_gen.reserved_output_path = Cesta '{ $path }' je vyhrazena pro stav sériových závislostí Netsuke. diff --git a/locales/cy/messages.ftl b/locales/cy/messages.ftl index 81c6c550d..6a2f62adc 100644 --- a/locales/cy/messages.ftl +++ b/locales/cy/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = Mewnosodiad annilys yn y gorchymyn: { $snippet }. # Gwallau cynhyrchu Ninja. ninja_gen.missing_action = Mae'r weithred ‘{ $id }’ y cyfeirir ati gan ymyl adeiladu ar goll. +ninja_gen.console_dependency_only = Ni all ymyl adeiladu ‘{ $output }’ ddewis y gronfa consol am mai dibyniaethau yn unig yw ei weithred. +ninja_gen.console_pool_conflict = Mae ymyl adeiladu ‘{ $output }’ yn dewis y gronfa consol, ond mae ei weithred yn gofyn am gronfa addasedig ‘{ $pool }’. ninja_gen.format = Methwyd â fformatio allbwn y maniffest Ninja. ninja_gen.dyndep_files_required = Mae'r adeilad hwn yn gofyn am fwndel Ninja a gynhyrchwyd; defnyddiwch `netsuke build`, `netsuke clean` neu `netsuke generate` er mwyn deunyddoli ffeiliau dyndep. ninja_gen.reserved_output_path = Mae'r llwybr '{ $path }' wedi'i gadw ar gyfer cyflwr dibyniaethau cyfresol Netsuke. diff --git a/locales/da/messages.ftl b/locales/da/messages.ftl index 7ba601867..42eef41e7 100644 --- a/locales/da/messages.ftl +++ b/locales/da/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = Ugyldig indsættelse i kommandoen: { $snippet }. # Fejl under generering af Ninja. ninja_gen.missing_action = Handlingen "{ $id }", som en byggekant henviser til, mangler. +ninja_gen.console_dependency_only = Byggekanten ‘{ $output }’ kan ikke vælge konsolpuljen, fordi handlingen kun indeholder afhængigheder. +ninja_gen.console_pool_conflict = Byggekanten ‘{ $output }’ vælger konsolpuljen, men handlingen anmoder om den brugerdefinerede pulje ‘{ $pool }’. ninja_gen.format = Ninja-manifestets output kunne ikke formateres. ninja_gen.dyndep_files_required = Dette build kræver en genereret Ninja-pakke; brug `netsuke build`, `netsuke clean` eller `netsuke generate`, så dyndep-filerne materialiseres. ninja_gen.reserved_output_path = Stien '{ $path }' er reserveret til Netsukes serielle afhængighedstilstand. diff --git a/locales/de/messages.ftl b/locales/de/messages.ftl index d6a324641..3059b4084 100644 --- a/locales/de/messages.ftl +++ b/locales/de/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = Ungültige Befehlsinterpolation: { $snippet }. # Fehler bei der Ninja-Erzeugung. ninja_gen.missing_action = Die von einer Build-Kante referenzierte Aktion „{ $id }“ fehlt. +ninja_gen.console_dependency_only = Die Build-Kante „{ $output }“ kann den Konsolen-Pool nicht auswählen, weil ihre Aktion nur Abhängigkeiten enthält. +ninja_gen.console_pool_conflict = Die Build-Kante „{ $output }“ wählt den Konsolen-Pool aus, aber ihre Aktion fordert den benutzerdefinierten Pool „{ $pool }“ an. ninja_gen.format = Die Ausgabe des Ninja-Manifests konnte nicht formatiert werden. ninja_gen.dyndep_files_required = Dieser Build erfordert ein generiertes Ninja-Bundle; verwenden Sie `netsuke build`, `netsuke clean` oder `netsuke generate`, damit die dyndep-Dateien materialisiert werden. ninja_gen.reserved_output_path = Der Pfad '{ $path }' ist für den seriellen Abhängigkeitsstatus von Netsuke reserviert. diff --git a/locales/el/messages.ftl b/locales/el/messages.ftl index 84cfbe93c..3417aaa67 100644 --- a/locales/el/messages.ftl +++ b/locales/el/messages.ftl @@ -199,6 +199,8 @@ ir.invalid_command = Μη έγκυρη παρεμβολή στην εντολή: # Σφάλματα παραγωγής αρχείων Ninja. ninja_gen.missing_action = Λείπει η ενέργεια «{ $id }» στην οποία παραπέμπει ακμή δόμησης. +ninja_gen.console_dependency_only = Η ακμή δόμησης «{ $output }» δεν μπορεί να επιλέξει την ομάδα κονσόλας, επειδή η ενέργειά της περιλαμβάνει μόνο εξαρτήσεις. +ninja_gen.console_pool_conflict = Η ακμή δόμησης «{ $output }» επιλέγει την ομάδα κονσόλας, αλλά η ενέργειά της ζητά την προσαρμοσμένη ομάδα «{ $pool }». ninja_gen.format = Δεν ήταν δυνατή η μορφοποίηση της εξόδου του δηλωτικού Ninja. ninja_gen.dyndep_files_required = Αυτή η δόμηση απαιτεί παραγόμενο πακέτο Ninja· χρησιμοποιήστε `netsuke build`, `netsuke clean` ή `netsuke generate`, ώστε να δημιουργηθούν τα αρχεία dyndep. ninja_gen.reserved_output_path = Η διαδρομή '{ $path }' είναι δεσμευμένη για την κατάσταση σειριακών εξαρτήσεων του Netsuke. diff --git a/locales/en-GB/messages.ftl b/locales/en-GB/messages.ftl index b645b694d..17ca779e4 100644 --- a/locales/en-GB/messages.ftl +++ b/locales/en-GB/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = Invalid command interpolation: { $snippet }. # Ninja generation errors. ninja_gen.missing_action = Missing action '{ $id }' referenced by a build edge. +ninja_gen.console_dependency_only = Build edge '{ $output }' cannot select the console pool because its action is dependency-only. +ninja_gen.console_pool_conflict = Build edge '{ $output }' selects the console pool but its action requests custom pool '{ $pool }'. ninja_gen.format = Failed to format the Ninja manifest output. ninja_gen.dyndep_files_required = This build requires a generated Ninja bundle; use `netsuke build`, `netsuke clean`, or `netsuke generate` so the dyndep files are materialised. ninja_gen.reserved_output_path = The path '{ $path }' is reserved for Netsuke's serial dependency state. diff --git a/locales/en-US/messages.ftl b/locales/en-US/messages.ftl index 4d61e6e30..3f2e0cb48 100644 --- a/locales/en-US/messages.ftl +++ b/locales/en-US/messages.ftl @@ -201,6 +201,8 @@ ir.invalid_command = Invalid command interpolation: { $snippet }. # Ninja generation errors. ninja_gen.missing_action = Missing action '{ $id }' referenced by a build edge. +ninja_gen.console_dependency_only = Build edge '{ $output }' cannot select the console pool because its action is dependency-only. +ninja_gen.console_pool_conflict = Build edge '{ $output }' selects the console pool but its action requests custom pool '{ $pool }'. ninja_gen.format = Failed to format the Ninja manifest output. ninja_gen.dyndep_files_required = This build requires a generated Ninja bundle; use `netsuke build`, `netsuke clean`, or `netsuke generate` so the dyndep files are materialized. ninja_gen.reserved_output_path = The path '{ $path }' is reserved for Netsuke's serial dependency state. diff --git a/locales/es-419/messages.ftl b/locales/es-419/messages.ftl index a253521b6..3f341cd66 100644 --- a/locales/es-419/messages.ftl +++ b/locales/es-419/messages.ftl @@ -199,6 +199,8 @@ ir.invalid_command = Interpolación de comando no válida: { $snippet }. # Errores de generación de Ninja. ninja_gen.missing_action = Falta la acción '{ $id }' referenciada por una arista de compilación. +ninja_gen.console_dependency_only = La arista de compilación «{ $output }» no puede seleccionar el grupo de consola porque su acción solo declara dependencias. +ninja_gen.console_pool_conflict = La arista de compilación «{ $output }» selecciona el grupo de consola, pero su acción solicita el grupo personalizado «{ $pool }». ninja_gen.format = No se pudo dar formato a la salida del manifiesto de Ninja. ninja_gen.dyndep_files_required = Esta compilación requiere un paquete Ninja generado; use `netsuke build`, `netsuke clean` o `netsuke generate` para materializar los archivos dyndep. ninja_gen.reserved_output_path = La ruta '{ $path }' está reservada para el estado de dependencias seriales de Netsuke. diff --git a/locales/es-ES/messages.ftl b/locales/es-ES/messages.ftl index 50a5357a5..1f0e066c7 100644 --- a/locales/es-ES/messages.ftl +++ b/locales/es-ES/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = Interpolación de comando inválida: { $snippet }. # Errores de generación de Ninja. ninja_gen.missing_action = Falta la acción '{ $id }' referenciada por un borde de compilación. +ninja_gen.console_dependency_only = El borde de compilación «{ $output }» no puede seleccionar el grupo de consola porque su acción solo declara dependencias. +ninja_gen.console_pool_conflict = El borde de compilación «{ $output }» selecciona el grupo de consola, pero su acción solicita el grupo personalizado «{ $pool }». ninja_gen.format = No se pudo formatear la salida del manifiesto Ninja. ninja_gen.dyndep_files_required = Esta compilación requiere un paquete Ninja generado; use `netsuke build`, `netsuke clean` o `netsuke generate` para materializar los archivos dyndep. ninja_gen.reserved_output_path = La ruta '{ $path }' está reservada para el estado de dependencias seriales de Netsuke. diff --git a/locales/fa/messages.ftl b/locales/fa/messages.ftl index 4e7e4fd04..a6a413aa9 100644 --- a/locales/fa/messages.ftl +++ b/locales/fa/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = درج نامعتبر در فرمان: { $snippet }. # خطاهای تولید پرونده‌های Ninja. ninja_gen.missing_action = کنش «{ $id }» که یک یال ساخت به آن ارجاع می‌دهد وجود ندارد. +ninja_gen.console_dependency_only = ‏یال ساخت «{ $output }» نمی‌تواند مخزن کنسول را انتخاب کند، زیرا کنش آن فقط شامل وابستگی‌هاست. +ninja_gen.console_pool_conflict = ‏یال ساخت «{ $output }» مخزن کنسول را انتخاب می‌کند، اما کنش آن مخزن سفارشی «{ $pool }» را درخواست می‌کند. ninja_gen.format = قالب‌بندی خروجی مانیفست Ninja ممکن نشد. ninja_gen.dyndep_files_required = ‏ این ساخت به بستهٔ تولیدشدهٔ Ninja نیاز دارد؛ از `netsuke build`، `netsuke clean` یا `netsuke generate` استفاده کنید تا فایل‌های dyndep ایجاد شوند. ninja_gen.reserved_output_path = ‏ مسیر '{ $path }' برای وضعیت وابستگی‌های ترتیبی Netsuke رزرو شده است. diff --git a/locales/fi/messages.ftl b/locales/fi/messages.ftl index 1dc4f9272..40edcbce5 100644 --- a/locales/fi/messages.ftl +++ b/locales/fi/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = Virheellinen komennon sijoitus: { $snippet }. # Ninja-generoinnin virheet. ninja_gen.missing_action = Toiminto ”{ $id }”, johon koontikaari viittaa, puuttuu. +ninja_gen.console_dependency_only = Koontireuna ”{ $output }” ei voi valita konsolipoolia, koska sen toiminto sisältää vain riippuvuuksia. +ninja_gen.console_pool_conflict = Koontireuna ”{ $output }” valitsee konsolipoolin, mutta sen toiminto pyytää mukautettua poolia ”{ $pool }”. ninja_gen.format = Ninja-manifestin tulostetta ei voitu muotoilla. ninja_gen.dyndep_files_required = Tämä toiminto vaatii luodun Ninja-paketin; käytä komentoa `netsuke build`, `netsuke clean` tai `netsuke generate`, jotta dyndep-tiedostot luodaan. ninja_gen.reserved_output_path = Polku '{ $path }' on varattu Netsuken sarjariippuvuuksien tilan tallentamiseen. diff --git a/locales/fr/messages.ftl b/locales/fr/messages.ftl index bd7b18cf1..3db862029 100644 --- a/locales/fr/messages.ftl +++ b/locales/fr/messages.ftl @@ -199,6 +199,8 @@ ir.invalid_command = Interpolation de commande non valide : { $snippet }. # Erreurs de génération Ninja. ninja_gen.missing_action = Action « { $id } » manquante alors qu'une arête de compilation la référence. +ninja_gen.console_dependency_only = L’arête de compilation « { $output } » ne peut pas sélectionner le pool console, car son action ne contient que des dépendances. +ninja_gen.console_pool_conflict = L’arête de compilation « { $output } » sélectionne le pool console, mais son action demande le pool personnalisé « { $pool } ». ninja_gen.format = Impossible de formater la sortie du manifeste Ninja. ninja_gen.dyndep_files_required = Cette opération nécessite un bundle Ninja généré ; utilisez `netsuke build`, `netsuke clean` ou `netsuke generate` pour matérialiser les fichiers dyndep. ninja_gen.reserved_output_path = Le chemin '{ $path }' est réservé à l’état des dépendances séquentielles de Netsuke. diff --git a/locales/gd/messages.ftl b/locales/gd/messages.ftl index 5937807a8..64aa37306 100644 --- a/locales/gd/messages.ftl +++ b/locales/gd/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = Cur a-steach mì-dhligheach san àithne: { $snippet }. # Mearachdan dèanamh Ninja. ninja_gen.missing_action = Tha an gnìomh “{ $id }” air a bheil oir togail a' toirt iomradh a dhìth. +ninja_gen.console_dependency_only = Chan urrainn do dh’iomall togail ‘{ $output }’ amar a’ chonsail a thaghadh oir chan eil san ghnìomh aige ach eisimeileachdan. +ninja_gen.console_pool_conflict = Tha iomall togail ‘{ $output }’ a’ taghadh amar a’ chonsail, ach tha an gnìomh aige ag iarraidh an amar ghnàthaichte ‘{ $pool }’. ninja_gen.format = Cha b' urrainnear às-chur an fhoirm-liosta Ninja fhòrmatadh. ninja_gen.dyndep_files_required = Feumaidh an togail seo pasgan Ninja a chaidh a chruthachadh; cleachd `netsuke build`, `netsuke clean` no `netsuke generate` gus na faidhlichean dyndep a chruthachadh. ninja_gen.reserved_output_path = Tha an t-slighe '{ $path }' glèidhte airson staid eisimeileachdan shreathach Netsuke. diff --git a/locales/he/messages.ftl b/locales/he/messages.ftl index 9b72587d2..a4126187d 100644 --- a/locales/he/messages.ftl +++ b/locales/he/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = שיבוץ לא תקין בפקודה: { $snippet }. # שגיאות ביצירת קובצי Ninja. ninja_gen.missing_action = הפעולה „{ $id }” שאליה מפנה קשת בנייה חסרה. +ninja_gen.console_dependency_only = ‏קשת הבנייה „{ $output }” אינה יכולה לבחור במאגר המסוף, כי הפעולה שלה כוללת תלויות בלבד. +ninja_gen.console_pool_conflict = ‏קשת הבנייה „{ $output }” בוחרת במאגר המסוף, אך הפעולה שלה מבקשת את המאגר המותאם אישית „{ $pool }”. ninja_gen.format = לא ניתן היה לעצב את פלט מניפסט Ninja. ninja_gen.dyndep_files_required = ‏בנייה זו דורשת חבילת Ninja שנוצרה; השתמשו ב־`netsuke build`, ב־`netsuke clean` או ב־`netsuke generate` כדי ליצור את קובצי dyndep. ninja_gen.reserved_output_path = ‏הנתיב '{ $path }' שמור למצב התלויות הסדרתיות של Netsuke. diff --git a/locales/hi/messages.ftl b/locales/hi/messages.ftl index 9032071e9..6ebd4cd5a 100644 --- a/locales/hi/messages.ftl +++ b/locales/hi/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = आदेश में अमान्य प्रक् # Ninja निर्माण की त्रुटियाँ। ninja_gen.missing_action = किसी बिल्ड कोर द्वारा संदर्भित क्रिया “{ $id }” अनुपस्थित है। +ninja_gen.console_dependency_only = बिल्ड एज ‘{ $output }’ कंसोल पूल नहीं चुन सकता, क्योंकि इसकी क्रिया केवल निर्भरताएँ बताती है। +ninja_gen.console_pool_conflict = बिल्ड एज ‘{ $output }’ कंसोल पूल चुनता है, लेकिन इसकी क्रिया कस्टम पूल ‘{ $pool }’ का अनुरोध करती है। ninja_gen.format = Ninja मैनिफ़ेस्ट का निर्गम स्वरूपित नहीं किया जा सका। ninja_gen.dyndep_files_required = इस बिल्ड के लिए जनरेट किया गया Ninja बंडल आवश्यक है; dyndep फ़ाइलों को मूर्त रूप देने के लिए `netsuke build`, `netsuke clean` या `netsuke generate` का उपयोग करें। ninja_gen.reserved_output_path = पथ '{ $path }' Netsuke की क्रमिक निर्भरता स्थिति के लिए आरक्षित है। diff --git a/locales/hu/messages.ftl b/locales/hu/messages.ftl index 51d1999a2..c62f6a4fd 100644 --- a/locales/hu/messages.ftl +++ b/locales/hu/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = Érvénytelen behelyettesítés a parancsban: { $snippet }. # A Ninja-fájlok előállításának hibái. ninja_gen.missing_action = Hiányzik a(z) „{ $id }” művelet, amelyre egy építési él hivatkozik. +ninja_gen.console_dependency_only = A(z) „{ $output }” build él nem választhatja a konzolkészletet, mert a művelete csak függőségeket tartalmaz. +ninja_gen.console_pool_conflict = A(z) „{ $output }” build él a konzolkészletet választja, de a művelete a(z) „{ $pool }” egyéni készletet kéri. ninja_gen.format = A Ninja-jegyzék kimenetét nem sikerült formázni. ninja_gen.dyndep_files_required = Ehhez a művelethez generált Ninja-csomag szükséges; a dyndep fájlok létrehozásához használja a `netsuke build`, `netsuke clean` vagy `netsuke generate` parancsot. ninja_gen.reserved_output_path = A(z) '{ $path }' útvonal a Netsuke soros függőségi állapota számára van fenntartva. diff --git a/locales/id/messages.ftl b/locales/id/messages.ftl index 5b001d617..bd58065d0 100644 --- a/locales/id/messages.ftl +++ b/locales/id/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = Penyisipan tidak sah pada perintah: { $snippet }. # Galat pembuatan berkas Ninja. ninja_gen.missing_action = Tindakan "{ $id }" yang dirujuk sebuah sisi build tidak ada. +ninja_gen.console_dependency_only = Edge build ‘{ $output }’ tidak dapat memilih pool konsol karena aksinya hanya berisi dependensi. +ninja_gen.console_pool_conflict = Edge build ‘{ $output }’ memilih pool konsol, tetapi aksinya meminta pool khusus ‘{ $pool }’. ninja_gen.format = Keluaran manifes Ninja tidak dapat diformat. ninja_gen.dyndep_files_required = Build ini memerlukan bundel Ninja yang dibuat; gunakan `netsuke build`, `netsuke clean`, atau `netsuke generate` agar berkas dyndep diwujudkan. ninja_gen.reserved_output_path = Jalur '{ $path }' dicadangkan untuk status dependensi serial Netsuke. diff --git a/locales/it/messages.ftl b/locales/it/messages.ftl index e8dc0e0c7..9b39d61c0 100644 --- a/locales/it/messages.ftl +++ b/locales/it/messages.ftl @@ -199,6 +199,8 @@ ir.invalid_command = Interpolazione del comando non valida: { $snippet }. # Errori di generazione Ninja. ninja_gen.missing_action = Manca l'azione «{ $id }» referenziata da un arco di build. +ninja_gen.console_dependency_only = L’arco di build ‘{ $output }’ non può selezionare il pool console perché la sua azione dichiara solo dipendenze. +ninja_gen.console_pool_conflict = L’arco di build ‘{ $output }’ seleziona il pool console, ma la sua azione richiede il pool personalizzato ‘{ $pool }’. ninja_gen.format = Impossibile formattare l'output del manifest Ninja. ninja_gen.dyndep_files_required = Questa operazione richiede un bundle Ninja generato; usare `netsuke build`, `netsuke clean` o `netsuke generate` per materializzare i file dyndep. ninja_gen.reserved_output_path = Il percorso '{ $path }' è riservato allo stato delle dipendenze seriali di Netsuke. diff --git a/locales/ja/messages.ftl b/locales/ja/messages.ftl index 3510404f7..cd67e2b11 100644 --- a/locales/ja/messages.ftl +++ b/locales/ja/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = コマンドの補間が無効です: { $snippet }。 # Ninja 生成のエラー。 ninja_gen.missing_action = ビルド辺が参照するアクション「{ $id }」がありません。 +ninja_gen.console_dependency_only = ビルドエッジ「{ $output }」はコンソールプールを選択できません。アクションが依存関係のみだからです。 +ninja_gen.console_pool_conflict = ビルドエッジ「{ $output }」はコンソールプールを選択していますが、アクションはカスタムプール「{ $pool }」を要求しています。 ninja_gen.format = Ninja マニフェストの出力を整形できませんでした。 ninja_gen.dyndep_files_required = この操作には生成済み Ninja バンドルが必要です。dyndep ファイルを具体化するには `netsuke build`、`netsuke clean`、または `netsuke generate` を使用してください。 ninja_gen.reserved_output_path = パス '{ $path }' は Netsuke の直列依存関係の状態用に予約されています。 diff --git a/locales/ko/messages.ftl b/locales/ko/messages.ftl index 6938f3357..342d2b280 100644 --- a/locales/ko/messages.ftl +++ b/locales/ko/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = 명령의 보간이 잘못되었습니다: { $snippet }. # Ninja 생성 오류. ninja_gen.missing_action = 빌드 간선이 참조하는 동작 '{ $id }'이(가) 없습니다. +ninja_gen.console_dependency_only = 빌드 에지 ‘{ $output }’는 콘솔 풀을 선택할 수 없습니다. 작업이 종속성만으로 이루어져 있기 때문입니다. +ninja_gen.console_pool_conflict = 빌드 에지 ‘{ $output }’는 콘솔 풀을 선택하지만 작업은 사용자 지정 풀 ‘{ $pool }’을 요청합니다. ninja_gen.format = Ninja 매니페스트 출력의 서식을 지정하지 못했습니다. ninja_gen.dyndep_files_required = 이 작업에는 생성된 Ninja 번들이 필요합니다. dyndep 파일을 생성하려면 `netsuke build`, `netsuke clean` 또는 `netsuke generate`를 사용하세요. ninja_gen.reserved_output_path = 경로 '{ $path }'은(는) Netsuke의 직렬 종속성 상태용으로 예약되어 있습니다. diff --git a/locales/nb/messages.ftl b/locales/nb/messages.ftl index 0fc1212d8..3dd83df98 100644 --- a/locales/nb/messages.ftl +++ b/locales/nb/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = Ugyldig interpolasjon i kommandoen: { $snippet }. # Feil ved generering av Ninja. ninja_gen.missing_action = Handlingen «{ $id }» som en byggekant viser til, mangler. +ninja_gen.console_dependency_only = Byggekanten «{ $output }» kan ikke velge konsollpuljen fordi handlingen bare inneholder avhengigheter. +ninja_gen.console_pool_conflict = Byggekanten «{ $output }» velger konsollpuljen, men handlingen ber om den egendefinerte puljen «{ $pool }». ninja_gen.format = Utdataene fra Ninja-manifestet kunne ikke formateres. ninja_gen.dyndep_files_required = Denne operasjonen krever en generert Ninja-pakke. Bruk `netsuke build`, `netsuke clean` eller `netsuke generate` for å materialisere dyndep-filene. ninja_gen.reserved_output_path = Stien '{ $path }' er reservert for Netsukes serielle avhengighetstilstand. diff --git a/locales/nl/messages.ftl b/locales/nl/messages.ftl index 86da4412c..6a988dd4c 100644 --- a/locales/nl/messages.ftl +++ b/locales/nl/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = Ongeldige interpolatie in de opdracht: { $snippet }. # Fouten bij het genereren van Ninja. ninja_gen.missing_action = De actie ‘{ $id }’ waarnaar een bouwtak verwijst, ontbreekt. +ninja_gen.console_dependency_only = De buildrand ‘{ $output }’ kan de consolepool niet selecteren, omdat de actie alleen afhankelijkheden bevat. +ninja_gen.console_pool_conflict = De buildrand ‘{ $output }’ selecteert de consolepool, maar de actie vraagt om de aangepaste pool ‘{ $pool }’. ninja_gen.format = De uitvoer van het Ninja-manifest kon niet worden opgemaakt. ninja_gen.dyndep_files_required = Deze bewerking vereist een gegenereerde Ninja-bundel; gebruik `netsuke build`, `netsuke clean` of `netsuke generate` om de dyndep-bestanden te materialiseren. ninja_gen.reserved_output_path = Het pad '{ $path }' is gereserveerd voor de seriële afhankelijkheidsstatus van Netsuke. diff --git a/locales/pl/messages.ftl b/locales/pl/messages.ftl index c9e85900c..be78eabb0 100644 --- a/locales/pl/messages.ftl +++ b/locales/pl/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = Nieprawidłowa interpolacja w poleceniu: { $snippet }. # Błędy generowania plików Ninja. ninja_gen.missing_action = Brakuje akcji „{ $id }”, do której odwołuje się krawędź budowania. +ninja_gen.console_dependency_only = Krawędź kompilacji „{ $output }” nie może wybrać puli konsoli, ponieważ jej akcja zawiera tylko zależności. +ninja_gen.console_pool_conflict = Krawędź kompilacji „{ $output }” wybiera pulę konsoli, ale jej akcja żąda niestandardowej puli „{ $pool }”. ninja_gen.format = Nie udało się sformatować wyjścia manifestu Ninja. ninja_gen.dyndep_files_required = Ta operacja wymaga wygenerowanego pakietu Ninja; użyj `netsuke build`, `netsuke clean` lub `netsuke generate`, aby utworzyć pliki dyndep. ninja_gen.reserved_output_path = Ścieżka '{ $path }' jest zarezerwowana dla stanu szeregowych zależności Netsuke. diff --git a/locales/pt-BR/messages.ftl b/locales/pt-BR/messages.ftl index f32493e72..e220acc7d 100644 --- a/locales/pt-BR/messages.ftl +++ b/locales/pt-BR/messages.ftl @@ -199,6 +199,8 @@ ir.invalid_command = Interpolação de comando inválida: { $snippet }. # Erros de geração do Ninja. ninja_gen.missing_action = Falta a ação "{ $id }" referenciada por uma aresta de build. +ninja_gen.console_dependency_only = A aresta de compilação ‘{ $output }’ não pode selecionar o pool do console porque sua ação declara apenas dependências. +ninja_gen.console_pool_conflict = A aresta de compilação ‘{ $output }’ seleciona o pool do console, mas sua ação solicita o pool personalizado ‘{ $pool }’. ninja_gen.format = Não foi possível formatar a saída do manifesto do Ninja. ninja_gen.dyndep_files_required = Esta operação requer um pacote Ninja gerado; use `netsuke build`, `netsuke clean` ou `netsuke generate` para materializar os arquivos dyndep. ninja_gen.reserved_output_path = O caminho '{ $path }' está reservado para o estado de dependências seriais do Netsuke. diff --git a/locales/pt-PT/messages.ftl b/locales/pt-PT/messages.ftl index 72bc48302..567ca9edb 100644 --- a/locales/pt-PT/messages.ftl +++ b/locales/pt-PT/messages.ftl @@ -199,6 +199,8 @@ ir.invalid_command = Interpolação de comando inválida: { $snippet }. # Erros de geração do Ninja. ninja_gen.missing_action = Falta a ação «{ $id }» referenciada por uma aresta de compilação. +ninja_gen.console_dependency_only = A aresta de compilação ‘{ $output }’ não pode selecionar o conjunto da consola porque a sua ação só declara dependências. +ninja_gen.console_pool_conflict = A aresta de compilação ‘{ $output }’ seleciona o conjunto da consola, mas a sua ação pede o conjunto personalizado ‘{ $pool }’. ninja_gen.format = Não foi possível formatar a saída do manifesto Ninja. ninja_gen.dyndep_files_required = Esta operação requer um pacote Ninja gerado; utilize `netsuke build`, `netsuke clean` ou `netsuke generate` para materializar os ficheiros dyndep. ninja_gen.reserved_output_path = O caminho '{ $path }' está reservado para o estado de dependências seriais do Netsuke. diff --git a/locales/ro/messages.ftl b/locales/ro/messages.ftl index f150a29d8..3a587fc32 100644 --- a/locales/ro/messages.ftl +++ b/locales/ro/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = Interpolare nevalidă în comandă: { $snippet }. # Erori la generarea fișierelor Ninja. ninja_gen.missing_action = Lipsește acțiunea „{ $id }” la care face referire o muchie de construire. +ninja_gen.console_dependency_only = Muchia de construire „{ $output }” nu poate selecta grupul console, deoarece acțiunea sa conține doar dependențe. +ninja_gen.console_pool_conflict = Muchia de construire „{ $output }” selectează grupul console, dar acțiunea sa solicită grupul personalizat „{ $pool }”. ninja_gen.format = Ieșirea manifestului Ninja nu a putut fi formatată. ninja_gen.dyndep_files_required = Această operație necesită un pachet Ninja generat; utilizați `netsuke build`, `netsuke clean` sau `netsuke generate` pentru a materializa fișierele dyndep. ninja_gen.reserved_output_path = Calea '{ $path }' este rezervată pentru starea dependențelor seriale Netsuke. diff --git a/locales/ru/messages.ftl b/locales/ru/messages.ftl index 2b34cfb54..9a8fad417 100644 --- a/locales/ru/messages.ftl +++ b/locales/ru/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = Некорректная подстановка в кома # Ошибки генерации файлов Ninja. ninja_gen.missing_action = Отсутствует действие «{ $id }», на которое ссылается ребро сборки. +ninja_gen.console_dependency_only = Ребро сборки «{ $output }» не может выбрать пул консоли, поскольку действие содержит только зависимости. +ninja_gen.console_pool_conflict = Ребро сборки «{ $output }» выбирает пул консоли, но действие запрашивает пользовательский пул «{ $pool }». ninja_gen.format = Не удалось отформатировать вывод манифеста Ninja. ninja_gen.dyndep_files_required = Для этой операции требуется созданный пакет Ninja; используйте `netsuke build`, `netsuke clean` или `netsuke generate`, чтобы материализовать файлы dyndep. ninja_gen.reserved_output_path = Путь '{ $path }' зарезервирован для состояния последовательных зависимостей Netsuke. diff --git a/locales/sv/messages.ftl b/locales/sv/messages.ftl index c516c7a7f..3b0212fb2 100644 --- a/locales/sv/messages.ftl +++ b/locales/sv/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = Ogiltig interpolering i kommandot: { $snippet }. # Fel vid generering av Ninja. ninja_gen.missing_action = Åtgärden ”{ $id }” som en byggbåge hänvisar till saknas. +ninja_gen.console_dependency_only = Byggkanten ”{ $output }” kan inte välja konsolpoolen eftersom åtgärden bara innehåller beroenden. +ninja_gen.console_pool_conflict = Byggkanten ”{ $output }” väljer konsolpoolen, men åtgärden begär den anpassade poolen ”{ $pool }”. ninja_gen.format = Ninja-manifestets utdata kunde inte formateras. ninja_gen.dyndep_files_required = Den här åtgärden kräver ett genererat Ninja-paket; använd `netsuke build`, `netsuke clean` eller `netsuke generate` för att materialisera dyndep-filerna. ninja_gen.reserved_output_path = Sökvägen '{ $path }' är reserverad för Netsukes seriella beroendetillstånd. diff --git a/locales/th/messages.ftl b/locales/th/messages.ftl index 47a3deeda..0e81c2f12 100644 --- a/locales/th/messages.ftl +++ b/locales/th/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = การแทรกค่าในคำสั่งไ # ข้อผิดพลาดในการสร้างไฟล์ Ninja ninja_gen.missing_action = ไม่มีการกระทำ “{ $id }” ที่เส้นเชื่อมของการสร้างอ้างถึง +ninja_gen.console_dependency_only = ขอบเขตการสร้าง ‘{ $output }’ เลือกพูลคอนโซลไม่ได้ เพราะการกระทำมีเฉพาะการพึ่งพาเท่านั้น +ninja_gen.console_pool_conflict = ขอบเขตการสร้าง ‘{ $output }’ เลือกพูลคอนโซล แต่การกระทำร้องขอพูลที่กำหนดเอง ‘{ $pool }’ ninja_gen.format = จัดรูปแบบผลลัพธ์ของไฟล์รายการ Ninja ไม่สำเร็จ ninja_gen.dyndep_files_required = การดำเนินการนี้ต้องใช้บันเดิล Ninja ที่สร้างขึ้น ให้ใช้ `netsuke build`, `netsuke clean` หรือ `netsuke generate` เพื่อทำให้ไฟล์ dyndep พร้อมใช้งาน ninja_gen.reserved_output_path = เส้นทาง '{ $path }' สงวนไว้สำหรับสถานะการขึ้นต่อกันแบบลำดับของ Netsuke diff --git a/locales/tr/messages.ftl b/locales/tr/messages.ftl index 73c402125..1d48bdf39 100644 --- a/locales/tr/messages.ftl +++ b/locales/tr/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = Komutta geçersiz yerleştirme: { $snippet }. # Ninja üretimi hataları. ninja_gen.missing_action = Bir derleme kenarının başvurduğu "{ $id }" eylemi eksik. +ninja_gen.console_dependency_only = ‘{ $output }’ derleme kenarı, eylemi yalnızca bağımlılıklardan oluştuğu için konsol havuzunu seçemez. +ninja_gen.console_pool_conflict = ‘{ $output }’ derleme kenarı konsol havuzunu seçiyor, ancak eylemi özel ‘{ $pool }’ havuzunu istiyor. ninja_gen.format = Ninja bildiriminin çıktısı biçimlendirilemedi. ninja_gen.dyndep_files_required = Bu işlem oluşturulmuş bir Ninja paketi gerektiriyor; dyndep dosyalarını oluşturmak için `netsuke build`, `netsuke clean` veya `netsuke generate` kullanın. ninja_gen.reserved_output_path = '{ $path }' yolu Netsuke'nin seri bağımlılık durumu için ayrılmıştır. diff --git a/locales/uk/messages.ftl b/locales/uk/messages.ftl index 8bae729a3..f763a2097 100644 --- a/locales/uk/messages.ftl +++ b/locales/uk/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = Некоректна підстановка в команд # Помилки створення файлів Ninja. ninja_gen.missing_action = Відсутня дія «{ $id }», на яку посилається ребро збирання. +ninja_gen.console_dependency_only = Ребро збирання «{ $output }» не може вибрати пул консолі, оскільки його дія містить лише залежності. +ninja_gen.console_pool_conflict = Ребро збирання «{ $output }» вибирає пул консолі, але його дія запитує власний пул «{ $pool }». ninja_gen.format = Не вдалося відформатувати вивід маніфесту Ninja. ninja_gen.dyndep_files_required = Для цієї операції потрібні згенеровані Ninja dyndep файли; використовуйте `netsuke build`, `netsuke clean` або `netsuke generate`, щоб матеріалізувати їх. ninja_gen.reserved_output_path = Шлях '{ $path }' зарезервовано для стану послідовних залежностей Netsuke. diff --git a/locales/vi/messages.ftl b/locales/vi/messages.ftl index b53faf8f2..9068844da 100644 --- a/locales/vi/messages.ftl +++ b/locales/vi/messages.ftl @@ -198,6 +198,8 @@ ir.invalid_command = Nội suy không hợp lệ trong lệnh: { $snippet }. # Lỗi khi tạo tệp Ninja. ninja_gen.missing_action = Thiếu hành động “{ $id }” mà một cạnh dựng tham chiếu. +ninja_gen.console_dependency_only = Cạnh bản dựng ‘{ $output }’ không thể chọn nhóm console vì tác vụ của nó chỉ chứa các phụ thuộc. +ninja_gen.console_pool_conflict = Cạnh bản dựng ‘{ $output }’ chọn nhóm console, nhưng tác vụ yêu cầu nhóm tùy chỉnh ‘{ $pool }’. ninja_gen.format = Không định dạng được đầu ra của tệp kê khai Ninja. ninja_gen.dyndep_files_required = Thao tác này yêu cầu các tệp Ninja dyndep đã tạo; hãy dùng `netsuke build`, `netsuke clean` hoặc `netsuke generate` để hiện thực hóa chúng. ninja_gen.reserved_output_path = Đường dẫn '{ $path }' được dành riêng cho trạng thái phụ thuộc tuần tự của Netsuke. diff --git a/locales/zh-Hans/messages.ftl b/locales/zh-Hans/messages.ftl index 15f84353d..6b4237618 100644 --- a/locales/zh-Hans/messages.ftl +++ b/locales/zh-Hans/messages.ftl @@ -197,6 +197,8 @@ ir.invalid_command = 命令中的插值无效:{ $snippet }。 # Ninja 生成错误。 ninja_gen.missing_action = 缺少构建边引用的动作“{ $id }”。 +ninja_gen.console_dependency_only = 构建边“{ $output }”无法选择控制台池,因为它的动作仅包含依赖项。 +ninja_gen.console_pool_conflict = 构建边“{ $output }”选择了控制台池,但它的动作请求自定义池“{ $pool }”。 ninja_gen.format = 无法格式化 Ninja 清单的输出。 ninja_gen.dyndep_files_required = 此操作需要生成的 Ninja dyndep 文件;请使用 `netsuke build`、`netsuke clean` 或 `netsuke generate` 以生成这些文件。 ninja_gen.reserved_output_path = 路径“{ $path }”已保留给 Netsuke 的串行依赖状态。 diff --git a/locales/zh-Hant/messages.ftl b/locales/zh-Hant/messages.ftl index 3c90d87ee..b80f963dd 100644 --- a/locales/zh-Hant/messages.ftl +++ b/locales/zh-Hant/messages.ftl @@ -197,6 +197,8 @@ ir.invalid_command = 命令中的插值無效:{ $snippet }。 # Ninja 產生錯誤。 ninja_gen.missing_action = 缺少建置邊所參照的動作「{ $id }」。 +ninja_gen.console_dependency_only = 建置邊「{ $output }」無法選取主控台集區,因為它的動作只包含相依項目。 +ninja_gen.console_pool_conflict = 建置邊「{ $output }」選取了主控台集區,但它的動作要求自訂集區「{ $pool }」。 ninja_gen.format = 無法格式化 Ninja 資訊清單的輸出。 ninja_gen.dyndep_files_required = 此操作需要產生的 Ninja dyndep 檔案;請使用 `netsuke build`、`netsuke clean` 或 `netsuke generate` 以產生這些檔案。 ninja_gen.reserved_output_path = 路徑「{ $path }」已保留給 Netsuke 的序列相依性狀態。 diff --git a/public_api_fixture/src/lib.rs b/public_api_fixture/src/lib.rs index dadcc88ae..17fc02be6 100644 --- a/public_api_fixture/src/lib.rs +++ b/public_api_fixture/src/lib.rs @@ -6,6 +6,8 @@ //! compiled, and Cargo compiles them in the build phase with every other //! workspace library. See ADR-0059 for why the checks left the test suite. +#[path = "../../tests/ui/build_edge_console_compile_pass.rs"] +pub mod build_edge_console; pub mod cli_configuration; pub mod command_env; pub mod command_list; diff --git a/src/graph_view/tests/support.rs b/src/graph_view/tests/support.rs index fed8bcafa..aebfbe6b3 100644 --- a/src/graph_view/tests/support.rs +++ b/src/graph_view/tests/support.rs @@ -69,6 +69,7 @@ pub(super) fn add_edge(graph: &mut BuildGraph, fixture: EdgeFixture<'_>) { progress_label: None, phony: fixture.phony, always: fixture.always, + console: false, }; assert!( graph.insert_edge(edge).is_ok(), diff --git a/src/ir/cycle/property_tests/analyse_tests.rs b/src/ir/cycle/property_tests/analyse_tests.rs index 09c96b60a..7ec486a38 100644 --- a/src/ir/cycle/property_tests/analyse_tests.rs +++ b/src/ir/cycle/property_tests/analyse_tests.rs @@ -127,6 +127,7 @@ fn analyse_resolves_non_first_output_alias_as_dependency() { progress_label: None, phony: false, always: false, + console: false, }; graph .insert_edge(producer) diff --git a/src/ir/cycle/property_tests/issue322_property_tests.rs b/src/ir/cycle/property_tests/issue322_property_tests.rs index 53d956dab..ab9752389 100644 --- a/src/ir/cycle/property_tests/issue322_property_tests.rs +++ b/src/ir/cycle/property_tests/issue322_property_tests.rs @@ -51,6 +51,7 @@ fn build_edge(output: Utf8PathBuf) -> BuildEdge { progress_label: None, phony: false, always: false, + console: false, } } diff --git a/src/ir/cycle/property_tests/mod.rs b/src/ir/cycle/property_tests/mod.rs index 3e310df7f..433c0fe61 100644 --- a/src/ir/cycle/property_tests/mod.rs +++ b/src/ir/cycle/property_tests/mod.rs @@ -59,6 +59,7 @@ impl EdgeBuilder { progress_label: None, phony: false, always: false, + console: false, } } } diff --git a/src/ir/cycle/tests.rs b/src/ir/cycle/tests.rs index cf2592fe4..033905041 100644 --- a/src/ir/cycle/tests.rs +++ b/src/ir/cycle/tests.rs @@ -22,6 +22,7 @@ fn build_edge(inputs: &[&str], implicit_deps: &[&str], output: &str) -> BuildEdg progress_label: None, phony: false, always: false, + console: false, } } struct MissingDepsCase<'a> { diff --git a/src/ir/cycle/verification.rs b/src/ir/cycle/verification.rs index fe5b43a75..74fa8e00f 100644 --- a/src/ir/cycle/verification.rs +++ b/src/ir/cycle/verification.rs @@ -198,6 +198,7 @@ fn edge_with(spec: EdgeSpec<'_>) -> BuildEdge { order_only_deps: spec.order_only_deps, phony: false, always: false, + console: false, } } diff --git a/src/ir/from_manifest/mod.rs b/src/ir/from_manifest/mod.rs index 4b2337b88..814e75d90 100644 --- a/src/ir/from_manifest/mod.rs +++ b/src/ir/from_manifest/mod.rs @@ -172,6 +172,7 @@ impl BuildGraph { order_only_deps: to_paths(&target.order_only_deps), phony: target.phony, always: target.always, + console: target.console, }; #[cfg(not(kani))] diff --git a/src/ir/graph/mod.rs b/src/ir/graph/mod.rs index 6d46d70a8..a60593a50 100644 --- a/src/ir/graph/mod.rs +++ b/src/ir/graph/mod.rs @@ -296,6 +296,11 @@ pub struct BuildEdge { pub phony: bool, /// Run the command on every invocation regardless of timestamps. pub always: bool, + /// Run this executable edge in Ninja's serial console pool. + /// + /// Console selection belongs to an edge rather than its reusable action, + /// so actions shared by console and ordinary targets keep one identity. + pub console: bool, } mod error; diff --git a/src/localization/keys.rs b/src/localization/keys.rs index fa42d532b..1c4a811c8 100644 --- a/src/localization/keys.rs +++ b/src/localization/keys.rs @@ -123,6 +123,8 @@ define_keys! { IR_ACTION_SERIALISATION => "ir.action_serialisation", IR_INVALID_COMMAND => "ir.invalid_command", NINJA_GEN_MISSING_ACTION => "ninja_gen.missing_action", + NINJA_GEN_CONSOLE_DEPENDENCY_ONLY => "ninja_gen.console_dependency_only", + NINJA_GEN_CONSOLE_POOL_CONFLICT => "ninja_gen.console_pool_conflict", NINJA_GEN_FORMAT => "ninja_gen.format", NINJA_GEN_DYNDEP_FILES_REQUIRED => "ninja_gen.dyndep_files_required", NINJA_GEN_RESERVED_OUTPUT_PATH => "ninja_gen.reserved_output_path", diff --git a/src/ninja_gen/display_edge.rs b/src/ninja_gen/display_edge.rs index a26416960..fa0e5188b 100644 --- a/src/ninja_gen/display_edge.rs +++ b/src/ninja_gen/display_edge.rs @@ -39,6 +39,9 @@ impl Display for DisplayEdge<'_> { writeln!(output)?; write_flag!(output, "restat", self.edge.always && !self.action_restat); write_kv!(output, "description", self.description); + if self.edge.console { + writeln!(output, " pool = console")?; + } writeln!(output) } } diff --git a/src/ninja_gen/dyndep/mod.rs b/src/ninja_gen/dyndep/mod.rs index 8333be4fb..4d81d6ad7 100644 --- a/src/ninja_gen/dyndep/mod.rs +++ b/src/ninja_gen/dyndep/mod.rs @@ -54,6 +54,7 @@ //! progress_label: None, //! phony: false, //! always: false, +//! console: false, //! }).expect("test graph output aliases must be unique"); //! let bundle = generate_bundle(&graph).expect("generate bundle"); //! assert!(bundle.build_file().contains("ninja_required_version = 1.10")); @@ -92,7 +93,10 @@ const DYNDEP_NAMESPACE: &str = ".netsuke/dyndep"; /// Returns [`NinjaGenError::ReservedOutputPath`] when a user output or /// dependency collides with the reserved `.netsuke/serial` or /// `.netsuke/dyndep` namespace, and [`NinjaGenError::MissingAction`] when an -/// edge references an unknown action. +/// edge references an unknown action. Returns +/// [`NinjaGenError::ConsoleDependencyOnly`] when a console edge uses a +/// dependency-only action, and [`NinjaGenError::ConsolePoolConflict`] when its +/// action requests a conflicting custom pool. pub fn generate_bundle(graph: &BuildGraph) -> Result { generate_bundle_for_shell(graph, RecipeShell::host_default()) } @@ -137,9 +141,12 @@ pub(crate) fn generate_bundle_for_shell_with_bash( /// [`NinjaGenError::UnsupportedCommandListExec`], /// [`NinjaGenError::UnanalyzableCommandListEval`], or /// [`NinjaGenError::NinjaControlCharacter`] when an action recipe cannot be -/// rendered safely. Returns [`NinjaGenError::MissingAction`] when an edge -/// references an action absent from the graph, or [`NinjaGenError::Format`] -/// when rendering the bundle fails. +/// rendered safely. Returns [`NinjaGenError::ConsoleDependencyOnly`] when a +/// console edge uses a dependency-only action or +/// [`NinjaGenError::ConsolePoolConflict`] when its action requests a +/// conflicting custom pool. Returns [`NinjaGenError::MissingAction`] when an +/// edge references an action absent from the graph, or +/// [`NinjaGenError::Format`] when rendering the bundle fails. fn generate_bundle_inner( graph: &BuildGraph, shell: RecipeShell, @@ -147,6 +154,7 @@ fn generate_bundle_inner( ) -> Result { reject_unsupported_path_characters(graph)?; reject_reserved_paths(graph)?; + super::validation::validate_console_scheduling(graph)?; let serial_present = graph_requires_dyndep(graph); let mut out = String::new(); diff --git a/src/ninja_gen/dyndep/tests/console.rs b/src/ninja_gen/dyndep/tests/console.rs new file mode 100644 index 000000000..21ab95a62 --- /dev/null +++ b/src/ninja_gen/dyndep/tests/console.rs @@ -0,0 +1,47 @@ +//! Tests for console selection across staged serial dependency bundles. + +use super::{generate_bundle, graph_with_edge, serial_edge}; +use anyhow::{Result, ensure}; + +/// Keep console scheduling on the user edge, away from synthetic gates and sidecars. +#[test] +fn console_serial_parent_does_not_mark_gates_or_sidecars() -> Result<()> { + let mut edge = serial_edge("all", &["check-fmt", "lint"]); + edge.console = true; + let graph = graph_with_edge(edge); + let bundle = generate_bundle(&graph)?; + let file = bundle.build_file(); + let lines: Vec<_> = file.lines().collect(); + let parent_line = lines + .iter() + .position(|line| line.starts_with("build all: a |")) + .expect("console parent edge must be emitted"); + + ensure!( + lines.get(parent_line + 1) == Some(&" pool = console"), + "console binding must follow the parent build statement:\n{file}" + ); + ensure!( + lines + .iter() + .filter(|line| **line == " pool = console") + .count() + == 1, + "synthetic gate edges must not select the console pool:\n{file}" + ); + ensure!( + file.lines() + .filter(|line| line.starts_with("build .netsuke/serial/")) + .count() + == 2, + "expected two synthetic serial gates:\n{file}" + ); + ensure!( + bundle + .dyndep_files() + .iter() + .all(|sidecar| !sidecar.content().contains("console")), + "dyndep sidecars must not contain console scheduling metadata" + ); + Ok(()) +} diff --git a/src/ninja_gen/dyndep/tests/dependency_only.rs b/src/ninja_gen/dyndep/tests/dependency_only.rs index 524050765..c10136976 100644 --- a/src/ninja_gen/dyndep/tests/dependency_only.rs +++ b/src/ninja_gen/dyndep/tests/dependency_only.rs @@ -35,6 +35,7 @@ fn dependency_only_serial_bundle_uses_phony_without_a_recipe() -> Result<()> { progress_label: None, phony: true, always: false, + console: false, }) .expect("test graph output aliases must be unique"); diff --git a/src/ninja_gen/dyndep/tests/mod.rs b/src/ninja_gen/dyndep/tests/mod.rs index b94240eb5..514f6b370 100644 --- a/src/ninja_gen/dyndep/tests/mod.rs +++ b/src/ninja_gen/dyndep/tests/mod.rs @@ -33,6 +33,7 @@ fn serial_edge(output: &str, deps: &[&str]) -> BuildEdge { progress_label: None, phony: false, always: false, + console: false, } } @@ -348,8 +349,8 @@ fn repeated_dependency_keeps_separate_stage_sidecars() -> Result<()> { Ok(()) } +mod console; mod dependency_only; - mod multi_output; mod paths; diff --git a/src/ninja_gen/error.rs b/src/ninja_gen/error.rs index adc344ec1..29fc31fdf 100644 --- a/src/ninja_gen/error.rs +++ b/src/ninja_gen/error.rs @@ -15,6 +15,24 @@ pub enum NinjaGenError { /// Localized error message. message: LocalizedMessage, }, + /// A dependency-only edge cannot request Ninja's console pool. + #[error("{message}")] + ConsoleDependencyOnly { + /// First explicit output identifying the offending build edge. + output: String, + /// Localized error message. + message: LocalizedMessage, + }, + /// A console edge conflicts with the pool selected by its action. + #[error("{message}")] + ConsolePoolConflict { + /// First explicit output identifying the offending build edge. + output: String, + /// Action pool that conflicts with console scheduling. + pool: String, + /// Localized error message. + message: LocalizedMessage, + }, /// An action built outside manifest deserialization has no command entries. #[error("command-list action {action_index} has no command entries")] EmptyCommandRecipe { diff --git a/src/ninja_gen/mod.rs b/src/ninja_gen/mod.rs index f2ee00e90..193fdd668 100644 --- a/src/ninja_gen/mod.rs +++ b/src/ninja_gen/mod.rs @@ -28,6 +28,7 @@ mod explicit_shell; mod escape; mod recipe_shell; mod validation; +use validation::validate_console_scheduling; pub use crate::recipe_shell::RecipeShell; pub use error::NinjaGenError; @@ -85,7 +86,7 @@ use order::{ordered_default_targets, ordered_edges}; /// dependency_order: netsuke::ir::DependencyOrder::Parallel, /// explicit_outputs: vec![Utf8PathBuf::from("out")], /// implicit_outputs: Vec::new(), order_only_deps: Vec::new(), -/// progress_label: None, phony: false, always: false +/// progress_label: None, phony: false, always: false, console: false /// }).expect("test graph output aliases must be unique"); /// # let result: Result<(), netsuke::ninja_gen::NinjaGenError> = (|| { /// let text = netsuke::ninja_gen::generate(&graph)?; @@ -104,7 +105,9 @@ use order::{ordered_default_targets, ordered_edges}; /// entry contains a Ninja control character, isolated mode is attached to a /// non-list command, a PowerShell recipe exceeds its size limit, an isolated /// PowerShell entry exceeds Windows' command-line limit, a graph path uses -/// Netsuke's reserved serial-ordering namespace, or writing to the output fails. +/// Netsuke's reserved serial-ordering namespace, a console edge uses a +/// dependency-only action or conflicts with its custom pool, or writing to the +/// output fails. pub fn generate(graph: &BuildGraph) -> Result { let mut out = String::new(); generate_into(graph, &mut out)?; @@ -133,7 +136,7 @@ pub fn generate(graph: &BuildGraph) -> Result { /// dependency_order: netsuke::ir::DependencyOrder::Parallel, /// explicit_outputs: vec![Utf8PathBuf::from("out")], /// implicit_outputs: Vec::new(), order_only_deps: Vec::new(), -/// progress_label: None, phony: false, always: false +/// progress_label: None, phony: false, always: false, console: false /// }).expect("test graph output aliases must be unique"); /// let mut out = String::new(); /// # let result: Result<(), netsuke::ninja_gen::NinjaGenError> = (|| { @@ -153,7 +156,9 @@ pub fn generate(graph: &BuildGraph) -> Result { /// entry contains a Ninja control character, isolated mode is attached to a /// non-list command, a PowerShell recipe exceeds its size limit, an isolated /// PowerShell entry exceeds Windows' command-line limit, a graph path uses -/// Netsuke's reserved serial-ordering namespace, or writing to the output fails. +/// Netsuke's reserved serial-ordering namespace, a console edge uses a +/// dependency-only action or conflicts with its custom pool, or writing to the +/// output fails. pub fn generate_into(graph: &BuildGraph, out: &mut W) -> Result<(), NinjaGenError> { generate_into_with_shell(graph, out, RecipeShell::host_default()) } @@ -172,6 +177,7 @@ pub(crate) fn generate_into_with_shell( let localizer = localization::localizer(); reject_unsupported_path_characters(graph)?; reject_reserved_paths(graph)?; + validate_console_scheduling(graph)?; if graph_requires_dyndep(graph) { return Err(NinjaGenError::DyndepFilesRequired { message: localization::message(keys::NINJA_GEN_DYNDEP_FILES_REQUIRED), diff --git a/src/ninja_gen/property_tests/dependency_only.rs b/src/ninja_gen/property_tests/dependency_only.rs index bbf35b60c..33f18bfca 100644 --- a/src/ninja_gen/property_tests/dependency_only.rs +++ b/src/ninja_gen/property_tests/dependency_only.rs @@ -42,6 +42,7 @@ fn dependency_only_graph(dependencies: &[String], order: DependencyOrder) -> Bui progress_label: None, phony: false, always: false, + console: false, }) .is_ok(), "test graph output aliases must be unique", diff --git a/src/ninja_gen/property_tests/mod.rs b/src/ninja_gen/property_tests/mod.rs index aa1ade048..4f392c787 100644 --- a/src/ninja_gen/property_tests/mod.rs +++ b/src/ninja_gen/property_tests/mod.rs @@ -56,6 +56,7 @@ fn edge_strategy_with_ranges( progress_label: None, phony: false, always: false, + console: false, } }, ) @@ -124,6 +125,7 @@ fn serial_graph(dependencies: Vec) -> BuildGraph { progress_label: None, phony: false, always: false, + console: false, }) .is_ok(), "test graph output aliases must be unique", diff --git a/src/ninja_gen/property_tests/ninja_oracle.rs b/src/ninja_gen/property_tests/ninja_oracle.rs index 17801b875..99409783e 100644 --- a/src/ninja_gen/property_tests/ninja_oracle.rs +++ b/src/ninja_gen/property_tests/ninja_oracle.rs @@ -52,6 +52,7 @@ pub(super) fn scalar_graph(command: String) -> BuildGraph { progress_label: None, phony: false, always: false, + console: false, }) .is_ok(), "test graph output aliases must be unique", diff --git a/src/ninja_gen/tests/console.rs b/src/ninja_gen/tests/console.rs new file mode 100644 index 000000000..27e51ec5f --- /dev/null +++ b/src/ninja_gen/tests/console.rs @@ -0,0 +1,109 @@ +//! Tests for edge-level Ninja console pool selection. + +use super::command_action; +use super::{NinjaGenError, generate, generate_bundle}; +use crate::ir::{BuildEdge, BuildGraph, DependencyOrder}; +use anyhow::{Result, ensure}; +use camino::Utf8PathBuf; + +/// Emit console selection on one edge while reusing the action's rule normally. +#[test] +fn console_pool_is_edge_level_and_keeps_shared_action_rules() -> Result<()> { + let mut graph = BuildGraph::default(); + graph + .actions + .insert("shared".into(), command_action("echo ready".into())); + for (output, console) in [("console.out", true), ("ordinary.out", false)] { + graph + .insert_edge(BuildEdge { + action_id: "shared".into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec![Utf8PathBuf::from(output)], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + console, + }) + .expect("test graph output aliases must be unique"); + } + + let ninja = generate(&graph)?; + ensure!( + ninja.matches("rule shared\n").count() == 1, + "shared action must have one rule definition:\n{ninja}" + ); + ensure!( + ninja.contains("build console.out: shared\n pool = console\n\n"), + "console binding must be emitted on its edge:\n{ninja}" + ); + ensure!( + ninja.contains("build ordinary.out: shared\n\n"), + "ordinary edge must not inherit console scheduling:\n{ninja}" + ); + Ok(()) +} + +/// Accept the redundant combination of an edge flag and `pool = console` metadata. +#[test] +fn console_pool_accepts_matching_action_pool() -> Result<()> { + let mut graph = BuildGraph::default(); + let mut action = command_action("echo ready".into()); + action.pool = Some("console".into()); + graph.actions.insert("shared".into(), action); + graph + .insert_edge(BuildEdge { + action_id: "shared".into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec![Utf8PathBuf::from("console.out")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + console: true, + }) + .expect("test graph output aliases must be unique"); + + let ninja = generate(&graph)?; + ensure!( + ninja.contains(" pool = console\n\nbuild console.out: shared\n pool = console\n\n"), + "matching action and edge pools must both be accepted:\n{ninja}" + ); + Ok(()) +} + +/// Preserve the existing missing-action error when a console edge has no action. +#[test] +fn console_validation_leaves_missing_actions_to_generation() -> Result<()> { + let mut graph = BuildGraph::default(); + graph + .insert_edge(BuildEdge { + action_id: "missing".into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec![Utf8PathBuf::from("console.out")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + console: true, + }) + .expect("test graph output aliases must be unique"); + + let generate_error = generate(&graph).expect_err("missing action must remain an error"); + ensure!( + matches!(generate_error, NinjaGenError::MissingAction { ref id, .. } if id == "missing"), + "unexpected direct generation error: {generate_error:?}" + ); + let bundle_error = generate_bundle(&graph).expect_err("missing action must remain an error"); + ensure!( + matches!(bundle_error, NinjaGenError::MissingAction { ref id, .. } if id == "missing"), + "unexpected bundle generation error: {bundle_error:?}" + ); + Ok(()) +} diff --git a/src/ninja_gen/tests/mod.rs b/src/ninja_gen/tests/mod.rs index e977094cd..268f6a51d 100644 --- a/src/ninja_gen/tests/mod.rs +++ b/src/ninja_gen/tests/mod.rs @@ -37,6 +37,7 @@ fn generate_simple_ninja() -> Result<()> { progress_label: None, phony: false, always: false, + console: false, }; let mut graph = BuildGraph::default(); graph.actions.insert("a".into(), action); @@ -73,6 +74,7 @@ fn string_generation_apis_reject_reserved_paths() -> Result<()> { progress_label: None, phony: false, always: false, + console: false, }; let mut graph = BuildGraph::default(); graph.actions.insert("reserved".into(), action); @@ -174,6 +176,7 @@ fn generate_script_ninja_round_trips() -> Result<()> { progress_label: None, phony: false, always: false, + console: false, }; let mut graph = BuildGraph::default(); graph.actions.insert("a".into(), action); @@ -210,6 +213,7 @@ fn generate_command_list_ninja_joins_a_fail_fast_chain() -> Result<()> { progress_label: None, phony: false, always: false, + console: false, }; let mut graph = BuildGraph::default(); graph.actions.insert("a".into(), action); @@ -330,11 +334,6 @@ fn unsafe_command_list_entries_return_typed_generation_errors( ); } -#[test] -fn assert_shell_command_tolerates_complex_syntax() { - let command = r#"/bin/sh -c "echo 'nested quotes' && echo \"double\" && (echo subshell)""#; - NamedAction::assert_shell_command(command); -} /// Generate Ninja text using the explicit POSIX compatibility renderer. fn generate_posix(graph: &BuildGraph) -> Result { let mut ninja = String::new(); @@ -343,8 +342,10 @@ fn generate_posix(graph: &BuildGraph) -> Result { } mod bash_binding; +mod console; mod isolated_command_list; #[path = "legacy_shell.rs"] mod power_shell; #[path = "shell_isolated.rs"] mod power_shell_isolated; +mod shell_command; diff --git a/src/ninja_gen/tests/shell_command.rs b/src/ninja_gen/tests/shell_command.rs new file mode 100644 index 000000000..61bf2f68e --- /dev/null +++ b/src/ninja_gen/tests/shell_command.rs @@ -0,0 +1,10 @@ +//! Tests for shell command text accepted by Ninja generation. + +use super::NamedAction; + +/// Retain support for shell command text with nested quoting and subshells. +#[test] +fn assert_shell_command_tolerates_complex_syntax() { + let command = r#"/bin/sh -c "echo 'nested quotes' && echo \"double\" && (echo subshell)""#; + NamedAction::assert_shell_command(command); +} diff --git a/src/ninja_gen/validation.rs b/src/ninja_gen/validation.rs index f6e833085..414bf3eed 100644 --- a/src/ninja_gen/validation.rs +++ b/src/ninja_gen/validation.rs @@ -3,6 +3,46 @@ use super::command_list::{CommandListEntry, CommandListEntryError, command_list_entry_error}; use super::{NinjaGenError, RecipeShell, escape::validate_ninja_value}; use crate::ast::{CommandIsolation, Recipe, StringOrList}; +use crate::ir::BuildGraph; +use crate::localization::{self, keys}; + +/// Reject console scheduling that conflicts with an edge's action. +/// +/// # Errors +/// +/// Returns an error when a console edge uses a dependency-only recipe or +/// conflicts with an action's custom pool. +pub(super) fn validate_console_scheduling(graph: &BuildGraph) -> Result<(), NinjaGenError> { + for edge in graph.edges().filter(|edge| edge.console) { + let Some(action) = graph.actions.get(&edge.action_id) else { + continue; + }; + let output = edge + .explicit_outputs + .first() + .map_or_else(|| "".to_owned(), ToString::to_string); + if action.recipe.is_dependency_only() { + return Err(NinjaGenError::ConsoleDependencyOnly { + message: localization::message(keys::NINJA_GEN_CONSOLE_DEPENDENCY_ONLY) + .with_arg("output", &output), + output, + }); + } + if let Some(pool) = action.pool.as_deref() { + validate_ninja_value(pool)?; + if pool != "console" { + return Err(NinjaGenError::ConsolePoolConflict { + message: localization::message(keys::NINJA_GEN_CONSOLE_POOL_CONFLICT) + .with_arg("output", &output) + .with_arg("pool", pool), + output, + pool: pool.to_owned(), + }); + } + } + } + Ok(()) +} /// Reject recipes the generated shell cannot execute with stable semantics. pub(super) fn validate_action_recipe( @@ -104,9 +144,11 @@ pub(super) fn validate_action_metadata(action: &crate::ir::Action) -> Result<(), mod tests { //! Verifies shell-specific recipe validation boundaries. - use super::{NinjaGenError, RecipeShell, validate_action_recipe}; + use super::{NinjaGenError, RecipeShell, validate_action_recipe, validate_console_scheduling}; use crate::ast::{CommandIsolation, Recipe, StringOrList}; - use crate::ir::Action; + use crate::ir::{Action, BuildEdge, BuildGraph, DependencyOrder}; + use crate::ninja_gen::{generate, generate_bundle}; + use camino::Utf8PathBuf; /// Construct one action with a supplied command recipe for validation tests. fn command_action(command: StringOrList) -> Action { @@ -123,6 +165,37 @@ mod tests { } } + /// Build one console edge whose recipe and pool can vary across cases. + fn console_graph(recipe: Recipe, pool: Option<&str>) -> BuildGraph { + let mut graph = BuildGraph::default(); + graph.actions.insert( + "console-action".into(), + Action { + recipe, + description: None, + depfile: None, + deps_format: None, + pool: pool.map(str::to_owned), + restat: false, + }, + ); + graph + .insert_edge(BuildEdge { + action_id: "console-action".into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec![Utf8PathBuf::from("console.out")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + console: true, + }) + .expect("test graph output aliases must be unique"); + graph + } + /// Accept multi-line scalar text because PowerShell receives it through its transport. #[test] fn power_shell_accepts_a_multi_line_scalar_command() { @@ -174,4 +247,110 @@ mod tests { Err(NinjaGenError::InvalidCommandIsolation { action_index: 1 }) )); } + + /// Reject console scheduling when the action is only a dependency marker. + #[test] + fn console_edges_reject_dependency_only_recipes() { + let graph = console_graph( + Recipe::Command { + command: StringOrList::Empty, + isolation: CommandIsolation::Shared, + }, + None, + ); + let error = validate_console_scheduling(&graph) + .expect_err("dependency-only console edge must fail validation"); + assert!(error.to_string().contains("console.out")); + assert!(matches!( + error, + NinjaGenError::ConsoleDependencyOnly { output, .. } if output == "console.out" + )); + } + + /// Reject custom action pools when the edge independently requests console scheduling. + #[test] + fn console_edges_reject_conflicting_action_pools() { + let graph = console_graph( + Recipe::Command { + command: "echo ready".into(), + isolation: CommandIsolation::Shared, + }, + Some("link_pool"), + ); + let error = validate_console_scheduling(&graph) + .expect_err("custom pool conflict must fail validation"); + let rendered = error.to_string(); + assert!(rendered.contains("console.out")); + assert!(rendered.contains("link_pool")); + assert!(matches!( + error, + NinjaGenError::ConsolePoolConflict { output, pool, .. } + if output == "console.out" && pool == "link_pool" + )); + } + + /// Accept an explicit `console` action pool when it matches the edge selection. + #[test] + fn console_edges_accept_matching_action_pools() { + let graph = console_graph( + Recipe::Command { + command: "echo ready".into(), + isolation: CommandIsolation::Shared, + }, + Some("console"), + ); + assert!(validate_console_scheduling(&graph).is_ok()); + } + + /// Return the same localized scheduling errors from direct and bundled generation. + #[test] + fn direct_and_bundle_generators_reject_invalid_console_scheduling() { + let dependency_only = console_graph( + Recipe::Command { + command: StringOrList::Empty, + isolation: CommandIsolation::Shared, + }, + None, + ); + let direct_error = generate(&dependency_only) + .expect_err("direct generation must reject dependency-only console edges"); + assert!(direct_error.to_string().contains("console.out")); + assert!(matches!( + direct_error, + NinjaGenError::ConsoleDependencyOnly { output, .. } if output == "console.out" + )); + let bundle_error = generate_bundle(&dependency_only) + .expect_err("bundle generation must reject dependency-only console edges"); + assert!(bundle_error.to_string().contains("console.out")); + assert!(matches!( + bundle_error, + NinjaGenError::ConsoleDependencyOnly { output, .. } if output == "console.out" + )); + + let pool_conflict = console_graph( + Recipe::Command { + command: "echo ready".into(), + isolation: CommandIsolation::Shared, + }, + Some("link_pool"), + ); + let direct_pool_error = generate(&pool_conflict) + .expect_err("direct generation must reject console pool conflicts"); + assert!(direct_pool_error.to_string().contains("console.out")); + assert!(direct_pool_error.to_string().contains("link_pool")); + assert!(matches!( + direct_pool_error, + NinjaGenError::ConsolePoolConflict { output, pool, .. } + if output == "console.out" && pool == "link_pool" + )); + let bundle_pool_error = generate_bundle(&pool_conflict) + .expect_err("bundle generation must reject console pool conflicts"); + assert!(bundle_pool_error.to_string().contains("console.out")); + assert!(bundle_pool_error.to_string().contains("link_pool")); + assert!(matches!( + bundle_pool_error, + NinjaGenError::ConsolePoolConflict { output, pool, .. } + if output == "console.out" && pool == "link_pool" + )); + } } diff --git a/src/runner/dyndep/generation_telemetry.rs b/src/runner/dyndep/generation_telemetry.rs index 7253d3558..32cd7ee21 100644 --- a/src/runner/dyndep/generation_telemetry.rs +++ b/src/runner/dyndep/generation_telemetry.rs @@ -67,6 +67,8 @@ fn record_outcome(span: &tracing::Span, result: &Result) -> const fn error_category(error: &NinjaGenError) -> &'static str { match error { NinjaGenError::MissingAction { .. } => "missing_action", + NinjaGenError::ConsoleDependencyOnly { .. } => "console_dependency_only", + NinjaGenError::ConsolePoolConflict { .. } => "console_pool_conflict", NinjaGenError::EmptyCommandRecipe { .. } | NinjaGenError::InvalidCommandIsolation { .. } | NinjaGenError::MultipleBackgroundJobs { .. } diff --git a/src/runner/tests/mod.rs b/src/runner/tests/mod.rs index 2b1f9e20e..fb0b4613e 100644 --- a/src/runner/tests/mod.rs +++ b/src/runner/tests/mod.rs @@ -246,6 +246,7 @@ fn ninja_text_propagates_typed_generation_errors() { progress_label: None, phony: false, always: false, + console: false, }) .expect("test graph output aliases must be unique"); diff --git a/tests/bdd/steps/manifest/actions.rs b/tests/bdd/steps/manifest/actions.rs new file mode 100644 index 000000000..a4e0a3c53 --- /dev/null +++ b/tests/bdd/steps/manifest/actions.rs @@ -0,0 +1,26 @@ +//! Action-specific step definitions for manifest parsing scenarios. + +use super::assert_action_console; +use crate::bdd::fixtures::TestWorld; +use anyhow::Result; +use rstest_bdd_macros::then; + +/// Assert that an action requests Ninja's console pool. +/// +/// # Errors +/// +/// Returns an error when the action is missing or does not request the pool. +#[then("the action {index:usize} requests console scheduling")] +fn action_requests_console_scheduling(world: &TestWorld, index: usize) -> Result<()> { + assert_action_console(world, index, true) +} + +/// Assert that an action keeps ordinary Ninja scheduling. +/// +/// # Errors +/// +/// Returns an error when the action is missing or requests the pool. +#[then("the action {index:usize} does not request console scheduling")] +fn action_does_not_request_console_scheduling(world: &TestWorld, index: usize) -> Result<()> { + assert_action_console(world, index, false) +} diff --git a/tests/bdd/steps/manifest/helpers.rs b/tests/bdd/steps/manifest/helpers.rs index fb01067aa..23eedf421 100644 --- a/tests/bdd/steps/manifest/helpers.rs +++ b/tests/bdd/steps/manifest/helpers.rs @@ -164,6 +164,21 @@ pub(super) fn assert_target_always(world: &TestWorld, index: usize, expected: bo }) } +/// Validate the console-scheduling flag on a target. +/// +/// # Errors +/// +/// Returns an error when the target is missing or its flag differs. +pub(super) fn assert_target_console(world: &TestWorld, index: usize, expected: bool) -> Result<()> { + with_target(world, index, |target| { + ensure!( + target.console == expected, + "target {index} console scheduling should be {expected}" + ); + Ok(()) + }) +} + /// Generic manifest collection count assertion. fn assert_manifest_count( world: &TestWorld, diff --git a/tests/bdd/steps/manifest/mod.rs b/tests/bdd/steps/manifest/mod.rs index 314bf57e8..e4f6d0aef 100644 --- a/tests/bdd/steps/manifest/mod.rs +++ b/tests/bdd/steps/manifest/mod.rs @@ -5,6 +5,7 @@ //! - `helpers.rs` - Typed assertion utilities and target accessor functions //! - `targets.rs` - Target-specific assertion steps +mod actions; pub(super) mod environment; mod helpers; mod targets; @@ -298,6 +299,21 @@ where with_manifest_error_context(world, result.context("manifest has not been parsed"))? } +/// Validate the console-scheduling flag on an action. +/// +/// # Errors +/// +/// Returns an error when the action is missing or its flag differs. +fn assert_action_console(world: &TestWorld, index: usize, expected: bool) -> Result<()> { + with_action(world, index, |action| { + ensure!( + action.console == expected, + "action {index} console scheduling should be {expected}" + ); + Ok(()) + }) +} + #[then("the manifest has {count:usize} actions")] fn manifest_has_actions(world: &TestWorld, count: usize) -> Result<()> { let result = world.manifest.with_ref(|m| { diff --git a/tests/bdd/steps/manifest/targets.rs b/tests/bdd/steps/manifest/targets.rs index 21c35de1a..8a725a592 100644 --- a/tests/bdd/steps/manifest/targets.rs +++ b/tests/bdd/steps/manifest/targets.rs @@ -5,9 +5,9 @@ use super::helpers::{ assert_macro_count, assert_macro_signature_eq, assert_target_always, assert_target_command_eq, - assert_target_count, assert_target_has_dep, assert_target_has_order_only_dep, - assert_target_has_source, assert_target_name_eq, assert_target_phony, assert_target_rule_eq, - assert_target_script_eq, with_target, + assert_target_console, assert_target_count, assert_target_has_dep, + assert_target_has_order_only_dep, assert_target_has_source, assert_target_name_eq, + assert_target_phony, assert_target_rule_eq, assert_target_script_eq, with_target, }; use super::{get_string_from_string_or_list, with_manifest_error_context}; use crate::bdd::fixtures::{RefCellOptionExt, TestWorld}; @@ -60,6 +60,26 @@ fn target_not_always(world: &TestWorld, index: usize) -> Result<()> { assert_target_always(world, index, false) } +/// Assert that a target requests Ninja's console pool. +/// +/// # Errors +/// +/// Returns an error when the target is missing or does not request the pool. +#[then("the target {index:usize} requests console scheduling")] +fn target_requests_console_scheduling(world: &TestWorld, index: usize) -> Result<()> { + assert_target_console(world, index, true) +} + +/// Assert that a target keeps ordinary Ninja scheduling. +/// +/// # Errors +/// +/// Returns an error when the target is missing or requests the pool. +#[then("the target {index:usize} does not request console scheduling")] +fn target_does_not_request_console_scheduling(world: &TestWorld, index: usize) -> Result<()> { + assert_target_console(world, index, false) +} + #[expect( clippy::shadow_reuse, reason = "rstest-bdd macro generates wrapper; FIXME: https://github.com/leynos/rstest-bdd/issues/381" diff --git a/tests/bdd/steps/ninja.rs b/tests/bdd/steps/ninja.rs index ec4cce8a7..f6495d3ec 100644 --- a/tests/bdd/steps/ninja.rs +++ b/tests/bdd/steps/ninja.rs @@ -7,13 +7,59 @@ use anyhow::{Context, Result, anyhow, ensure}; use camino::Utf8PathBuf; use cap_std::{ambient_authority, fs_utf8::Dir}; use mockable::{DefaultEnv, Env}; +use netsuke::ast::{CommandIsolation, Recipe, StringOrList}; +use netsuke::ir::{Action, BuildEdge, BuildGraph, DependencyOrder}; use netsuke::ninja_gen::{self, RecipeShell}; -use rstest_bdd_macros::{then, when}; +use rstest_bdd_macros::{given, then, when}; use std::process::Command; use test_support::ninja::ninja_integration_workspace; const SENTINEL: &str = "NETSUKE_TEST_SENTINEL"; +// --------------------------------------------------------------------------- +// Given steps +// --------------------------------------------------------------------------- + +/// Set up a dependency-only graph whose edge requests console scheduling. +/// +/// # Errors +/// +/// Returns an error when the graph edge cannot be inserted. +#[given("a dependency-only action graph with console scheduling")] +fn dependency_only_console_graph(world: &TestWorld) -> Result<()> { + let mut graph = BuildGraph::default(); + graph.actions.insert( + "aggregate".into(), + Action { + recipe: Recipe::Command { + command: StringOrList::Empty, + isolation: CommandIsolation::Shared, + }, + description: None, + depfile: None, + deps_format: None, + pool: None, + restat: false, + }, + ); + graph + .insert_edge(BuildEdge { + action_id: "aggregate".into(), + inputs: Vec::new(), + implicit_deps: vec!["lint".into(), "test".into()], + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec!["all".into()], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: true, + always: false, + console: true, + }) + .context("insert dependency-only console edge")?; + world.build_graph.set_value(graph); + Ok(()) +} + // --------------------------------------------------------------------------- // Typed helper functions // --------------------------------------------------------------------------- diff --git a/tests/console_pool_runtime_tests.rs b/tests/console_pool_runtime_tests.rs new file mode 100644 index 000000000..47861ea6a --- /dev/null +++ b/tests/console_pool_runtime_tests.rs @@ -0,0 +1,384 @@ +//! Real-process checks for prompt console output and exclusive console edges. +//! +//! Each scenario launches Netsuke with piped output and controls recipe +//! completion through files, proving behaviour at the Ninja process boundary. + +#![cfg(unix)] + +use anyhow::{Context, Result, bail, ensure}; +use assert_cmd::cargo::cargo_bin; +use camino::Utf8PathBuf; +use cap_std::{ambient_authority, fs_utf8::Dir}; +use mockable::{DefaultEnv, Env}; +use std::{ + ffi::OsString, + io::{self, BufRead, BufReader, Read}, + process::{Child, ChildStdout, Command, ExitStatus, Stdio}, + sync::mpsc::{self, Receiver, RecvTimeoutError}, + thread::{self, JoinHandle}, + time::{Duration, Instant}, +}; +use tempfile::TempDir; +use test_support::ninja::ninja_integration_workspace; + +const PROCESS_DEADLINE: Duration = Duration::from_secs(60); +const POLL_INTERVAL: Duration = Duration::from_millis(10); + +/// Release blocked recipes and reap Netsuke if a runtime assertion fails. +struct ChildCleanup { + child: Child, + workspace: Dir, + release_files: Vec, + finished: bool, +} + +impl ChildCleanup { + /// Retain the child and the files that unblock its recipes. + fn new(child: Child, workspace: Dir, release_files: Vec) -> Self { + Self { + child, + workspace, + release_files, + finished: false, + } + } + + /// Mark a successfully reaped child so cleanup does not send it a signal. + fn mark_finished(&mut self) { + self.finished = true; + } +} + +impl Drop for ChildCleanup { + fn drop(&mut self) { + for release_file in &self.release_files { + drop(self.workspace.write(release_file, b"")); + } + if !self.finished { + let deadline = Instant::now() + Duration::from_secs(2); + while matches!(self.child.try_wait(), Ok(None)) && Instant::now() < deadline { + thread::sleep(POLL_INTERVAL); + } + if matches!(self.child.try_wait(), Ok(None)) { + drop(self.child.kill()); + } + } + drop(self.child.wait()); + } +} + +/// Create a Netsuke child with a private configuration environment and piped output. +/// +/// # Errors +/// +/// Returns an error when the host PATH is unavailable or the child configuration +/// directory cannot be created. +fn netsuke_build(workspace: &TempDir, arguments: &[&str]) -> Result { + let path = host_path()?; + let config_home = workspace.path().join(".config"); + workspace_dir(workspace)? + .create_dir_all(".config") + .context("create child configuration directory")?; + let mut command = Command::new(cargo_bin!("netsuke")); + command + .current_dir(workspace.path()) + .env_clear() + .env("PATH", path) + .env("HOME", workspace.path()) + .env("XDG_CONFIG_HOME", config_home) + .args(["--progress", "never"]) + .args(arguments) + .stdin(Stdio::null()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()); + Ok(command) +} + +/// Open a capability directory for a temporary process workspace. +/// +/// # Errors +/// +/// Returns an error when the temporary path is not UTF-8 or cannot be opened. +fn workspace_dir(workspace: &TempDir) -> Result { + let root = Utf8PathBuf::from_path_buf(workspace.path().to_path_buf()) + .map_err(|path| anyhow::anyhow!("temporary workspace path is not UTF-8: {path:?}"))?; + Dir::open_ambient_dir(root, ambient_authority()).context("open temporary workspace") +} + +/// Read the host `PATH` for the isolated Netsuke child process. +/// +/// # Errors +/// +/// Returns an error when the host environment does not provide `PATH`. +fn host_path() -> Result { + DefaultEnv + .os_string("PATH") + .context("host PATH is required to run Ninja") +} + +/// Read stdout on a helper thread so the parent can observe lines before exit. +fn spawn_line_reader(stdout: ChildStdout) -> (Receiver>, JoinHandle<()>) { + let (sender, receiver) = mpsc::channel(); + let reader = thread::spawn(move || { + let mut reader = BufReader::new(stdout); + loop { + let mut line = String::new(); + match reader.read_line(&mut line) { + Ok(0) => break, + Ok(_) => drop(sender.send(Ok(line))), + Err(error) => { + drop(sender.send(Err(error))); + break; + } + } + } + }); + (receiver, reader) +} + +/// Return the next output line before the bounded deadline. +/// +/// # Errors +/// +/// Returns an error when stdout closes, fails to read, or misses the deadline. +fn receive_line(receiver: &Receiver>, deadline: Instant) -> Result { + let remaining = deadline.saturating_duration_since(Instant::now()); + ensure!(!remaining.is_zero(), "timed out waiting for Netsuke output"); + match receiver.recv_timeout(remaining) { + Ok(Ok(line)) => Ok(line), + Ok(Err(error)) => Err(error).context("read Netsuke stdout"), + Err(RecvTimeoutError::Timeout) => bail!("timed out waiting for Netsuke output"), + Err(RecvTimeoutError::Disconnected) => { + bail!("Netsuke stdout closed before the expected marker appeared") + } + } +} + +/// Wait for a marker line while the recipe remains blocked on its release file. +/// +/// # Errors +/// +/// Returns an error when stdout closes or the marker does not arrive in time. +fn wait_for_marker(receiver: &Receiver>, marker: &str) -> Result { + let deadline = Instant::now() + PROCESS_DEADLINE; + loop { + let line = receive_line(receiver, deadline)?; + if line.trim_start().starts_with(marker) { + return Ok(line); + } + } +} + +/// Wait for the Netsuke process to exit before the bounded deadline. +/// +/// # Errors +/// +/// Returns an error when process status cannot be read or the child misses the deadline. +fn wait_for_exit(child: &mut Child) -> Result { + let deadline = Instant::now() + PROCESS_DEADLINE; + loop { + if let Some(status) = child.try_wait().context("poll Netsuke child status")? { + return Ok(status); + } + ensure!( + Instant::now() < deadline, + "Netsuke build did not exit before the deadline" + ); + thread::sleep(POLL_INTERVAL); + } +} + +/// Require a successful build and retain the bounded child diagnostic on failure. +/// +/// # Errors +/// +/// Returns an error when the child does not exit successfully or stderr cannot be read. +fn finish_successfully(cleanup: &mut ChildCleanup) -> Result<()> { + let status = wait_for_exit(&mut cleanup.child)?; + let mut stderr = String::new(); + cleanup + .child + .stderr + .take() + .context("Netsuke child should expose stderr")? + .read_to_string(&mut stderr) + .context("read Netsuke stderr")?; + ensure!( + status.success(), + "Netsuke build failed with {status}: {stderr}" + ); + cleanup.mark_finished(); + Ok(()) +} + +/// Probe Ninja or skip this optional local runtime test when it is unavailable. +fn runtime_workspace() -> Option { + match ninja_integration_workspace() { + Ok(workspace) => Some(workspace), + Err(error) => { + tracing::warn!(%error, "skipping console runtime test without Ninja"); + None + } + } +} + +/// Forward a flushed console-pool marker before the blocked command completes. +#[test] +fn console_output_reaches_piped_stdout_before_the_recipe_exits() -> Result<()> { + let Some(workspace) = runtime_workspace() else { + return Ok(()); + }; + let root = workspace_dir(&workspace)?; + let release_file = Utf8PathBuf::from(".release-stream"); + root.write( + "Netsukefile", + concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: streamed\n", + " command: 'printf \"STREAM-READY\\n\"; while [ ! -f .release-stream ]; do sleep 0.02; done; touch streamed'\n", + " console: true\n", + ), + ) + .context("write streaming console manifest")?; + + let child = netsuke_build(&workspace, &["build", "streamed"])? + .spawn() + .context("spawn Netsuke streaming build")?; + let mut cleanup = ChildCleanup::new(child, root.try_clone()?, vec![release_file.clone()]); + let stdout = cleanup + .child + .stdout + .take() + .context("Netsuke child should expose stdout")?; + let (output, reader) = spawn_line_reader(stdout); + + wait_for_marker(&output, "STREAM-READY")?; + ensure!( + cleanup + .child + .try_wait() + .context("check streaming child status")? + .is_none(), + "the recipe exited before the parent released it" + ); + root.write(&release_file, b"release") + .context("release streaming recipe")?; + finish_successfully(&mut cleanup)?; + reader + .join() + .map_err(|_| anyhow::anyhow!("stdout forwarding thread panicked"))?; + ensure!( + root.exists("streamed"), + "the released recipe should create its output" + ); + Ok(()) +} + +/// Keep two console recipes from overlapping under a parallel Ninja job limit. +#[test] +fn console_edges_are_exclusive_with_parallel_jobs_enabled() -> Result<()> { + let Some(workspace) = runtime_workspace() else { + return Ok(()); + }; + let root = workspace_dir(&workspace)?; + let first_release = Utf8PathBuf::from(".release-one"); + let second_release = Utf8PathBuf::from(".release-two"); + root.write( + "Netsukefile", + concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: one\n", + " command: >-\n", + " mkdir .console-running 2>/dev/null || { echo COLLISION >&2; exit 1; };\n", + " printf 'START:one\\n';\n", + " while [ ! -f .release-one ]; do sleep 0.02; done;\n", + " rmdir .console-running; touch one; printf 'DONE:one\\n'\n", + " console: true\n", + " - name: two\n", + " command: >-\n", + " mkdir .console-running 2>/dev/null || { echo COLLISION >&2; exit 1; };\n", + " printf 'START:two\\n';\n", + " while [ ! -f .release-two ]; do sleep 0.02; done;\n", + " rmdir .console-running; touch two; printf 'DONE:two\\n'\n", + " console: true\n", + ), + ) + .context("write exclusive console manifest")?; + + let mut command = netsuke_build(&workspace, &["--jobs", "2", "build", "one", "two"])?; + let child = command + .spawn() + .context("spawn Netsuke console exclusivity build")?; + let mut cleanup = ChildCleanup::new( + child, + root.try_clone()?, + vec![first_release.clone(), second_release.clone()], + ); + let stdout = cleanup + .child + .stdout + .take() + .context("Netsuke child should expose stdout")?; + let (output, reader) = spawn_line_reader(stdout); + + let first_start = wait_for_marker(&output, "START:")?; + let first_output = parse_start_output(&first_start)?; + release_edge(&root, &first_output)?; + let second_start = wait_for_marker(&output, "START:")?; + let second_output = parse_start_output(&second_start)?; + ensure!( + first_output != second_output, + "the two console edges should each start once" + ); + release_edge(&root, &second_output)?; + finish_successfully(&mut cleanup)?; + reader + .join() + .map_err(|_| anyhow::anyhow!("stdout forwarding thread panicked"))?; + for target in ["one", "two"] { + ensure!( + root.exists(target), + "console edge {target} should complete after release" + ); + } + ensure!( + !root.exists(".console-running"), + "the shared sentinel should be removed after both recipes finish" + ); + Ok(()) +} + +/// Release one exclusivity-test recipe by its declared output name. +/// +/// # Errors +/// +/// Returns an error for unexpected output names or failed release-file writes. +fn release_edge(workspace: &Dir, output: &str) -> Result<()> { + ensure!( + matches!(output, "one" | "two"), + "unexpected output marker {output}" + ); + workspace + .write(format!(".release-{output}"), b"release") + .with_context(|| format!("release console edge {output}"))?; + Ok(()) +} + +/// Extract and validate the target name printed by the shared recipe. +/// +/// # Errors +/// +/// Returns an error when the marker is absent or does not name a test output. +fn parse_start_output(line: &str) -> Result { + let output = line + .split_once("START:") + .map(|(_, output)| output.trim()) + .context("console recipe start marker should include its output")?; + ensure!( + matches!(output, "one" | "two"), + "unexpected console output {output}" + ); + Ok(output.to_owned()) +} diff --git a/tests/data/console.yml b/tests/data/console.yml new file mode 100644 index 000000000..8415a251c --- /dev/null +++ b/tests/data/console.yml @@ -0,0 +1,16 @@ +netsuke_version: "1.0.0" +actions: + - name: streamed-action + rule: shared + console: true + - name: ordinary-action + rule: shared +rules: + - name: shared + command: "echo {{ outs }}" +targets: + - name: ordinary-target + rule: shared + - name: inline-console + command: "echo {{ outs }}" + console: true diff --git a/tests/documentation_examples_tests.rs b/tests/documentation_examples_tests.rs index 9d91327e0..6e91c11ab 100644 --- a/tests/documentation_examples_tests.rs +++ b/tests/documentation_examples_tests.rs @@ -46,6 +46,7 @@ const EXPECTED_EXAMPLE_IDS: &[&str] = &[ "guide-command-list-failure-ambiguous-diagnostic", "guide-command-list-failure-unique-diagnostic", "guide-complete-manifest", + "guide-console-target", "guide-configuration-observability", "guide-crates-io-install", "guide-direct-command-list", @@ -242,6 +243,7 @@ fn every_documented_fence_has_a_known_unique_identifier() -> Result<()> { #[case("guide-direct-command-list")] #[case("guide-command-available-manifest")] #[case("guide-serial-dependency-order-manifest")] +#[case("guide-console-target")] #[case("stdlib-yaml-syntax-manifest")] #[case("stdlib-jinja-syntax-manifest")] #[case("stdlib-optional-rustflags-manifest")] @@ -252,6 +254,26 @@ fn documented_manifest_generates_ninja(#[case] example_id: &str) -> Result<()> { assert_generates_valid_ninja(&run, example_id) } +/// Keep the console-pool guide example tied to its generated Ninja binding. +#[test] +fn documented_console_example_selects_the_console_pool() -> Result<()> { + let workspace = manifest_workspace("guide-console-target")?; + let run = run_netsuke_in(workspace.path(), &["--progress", "never", "generate"])?; + + assert_success(&run, "guide console-pool target")?; + let lint_edge = run + .stdout + .split_once("build lint:") + .map(|(_, following)| following.split("\nbuild ").next().unwrap_or(following)) + .context("generated Ninja should contain the lint build edge")?; + ensure!( + lint_edge.contains(" pool = console"), + "the documented lint target should select the console pool: {}", + run.stdout + ); + Ok(()) +} + #[test] fn serial_aggregate_example_generates_a_dependency_only_node() -> Result<()> { let workspace = manifest_workspace("guide-serial-dependency-order-manifest")?; diff --git a/tests/features/manifest.feature b/tests/features/manifest.feature index b28cabafa..648455a7b 100644 --- a/tests/features/manifest.feature +++ b/tests/features/manifest.feature @@ -15,6 +15,14 @@ Feature: Manifest Parsing Then the target 1 is phony And the target 1 is always rebuilt + Scenario: Parsing console scheduling for actions and targets + Given the manifest file "tests/data/console.yml" is parsed + When the flags are checked + Then the action 1 requests console scheduling + And the action 2 does not request console scheduling + And the target 1 does not request console scheduling + And the target 2 requests console scheduling + Scenario: A target in the 'actions' block is implicitly phony Given the manifest file "tests/data/actions.yml" is parsed When the flags are checked diff --git a/tests/features/ninja.feature b/tests/features/ninja.feature index d9c9199be..20edb57ff 100644 --- a/tests/features/ninja.feature +++ b/tests/features/ninja.feature @@ -12,6 +12,18 @@ Feature: Ninja file generation Then the ninja file contains "build clean:" And the ninja file contains "rm -rf build" + Scenario: Console actions emit an edge-level console pool binding + When the manifest file "tests/data/console.yml" is compiled to IR + And the ninja file is generated + Then the ninja file contains "build streamed-action:" + And the ninja file contains "build inline-console:" + And the ninja file contains " pool = console" + + Scenario: Dependency-only console edges are rejected + Given a dependency-only action graph with console scheduling + When the ninja file is generated + Then ninja generation fails with "dependency-only" + Scenario: The child shell receives the sentinel environment When the manifest file "tests/data/dollar_escaping.yml" is compiled to IR And the ninja file is generated diff --git a/tests/ir_tests.rs b/tests/ir_tests.rs index 02b581a36..e34613c59 100644 --- a/tests/ir_tests.rs +++ b/tests/ir_tests.rs @@ -43,6 +43,7 @@ fn create_action_and_edge() { progress_label: None, phony: false, always: true, + console: false, }; let mut graph = BuildGraph::default(); graph.actions.insert("id".into(), action); @@ -107,6 +108,7 @@ fn build_graph_indexes_canonical_targets() { progress_label: None, phony: false, always: false, + console: false, }; let edge_id = graph .insert_edge(edge1) @@ -141,6 +143,7 @@ fn build_graph_rejects_duplicate_aliases_before_mutation() { progress_label: None, phony: false, always: false, + console: false, }; let result = graph.insert_edge(edge); diff --git a/tests/ninja_dollar_escaping_tests.rs b/tests/ninja_dollar_escaping_tests.rs index 63e3e2cfa..3dbe1ee9c 100644 --- a/tests/ninja_dollar_escaping_tests.rs +++ b/tests/ninja_dollar_escaping_tests.rs @@ -67,6 +67,7 @@ fn graph(recipe: Recipe, input: &str, output: &str) -> Result { progress_label: None, phony: false, always: false, + console: false, }; let mut graph = BuildGraph::default(); graph.actions.insert("action".into(), action(recipe)); diff --git a/tests/ninja_gen_command_list_integration_tests.rs b/tests/ninja_gen_command_list_integration_tests.rs index 0e0ebfae3..43de244f0 100644 --- a/tests/ninja_gen_command_list_integration_tests.rs +++ b/tests/ninja_gen_command_list_integration_tests.rs @@ -36,6 +36,7 @@ fn run_command_list( progress_label: None, phony: false, always: false, + console: false, }; let mut graph = BuildGraph::default(); graph.actions.insert("chain".into(), action); diff --git a/tests/ninja_gen_command_list_process_integration_tests.rs b/tests/ninja_gen_command_list_process_integration_tests.rs index 6a1664939..f910a138f 100644 --- a/tests/ninja_gen_command_list_process_integration_tests.rs +++ b/tests/ninja_gen_command_list_process_integration_tests.rs @@ -77,6 +77,7 @@ fn run_command_list_with_progress( progress_label: progress_label.map(str::to_owned), phony: false, always: false, + console: false, }; let mut graph = BuildGraph::default(); graph.actions.insert("chain".into(), action); diff --git a/tests/ninja_gen_integration_tests.rs b/tests/ninja_gen_integration_tests.rs index 77c785325..c506fb0c5 100644 --- a/tests/ninja_gen_integration_tests.rs +++ b/tests/ninja_gen_integration_tests.rs @@ -54,6 +54,7 @@ fn ninja_integration_setup() -> Option { progress_label: None, phony: false, always: false, + console: false, }, target_name: Utf8PathBuf::from("out"), ninja_args: vec!["-n"], @@ -79,6 +80,7 @@ fn ninja_integration_setup() -> Option { progress_label: None, phony: false, always: false, + console: false, }, target_name: Utf8PathBuf::from("out"), ninja_args: vec!["out"], @@ -104,6 +106,7 @@ fn ninja_integration_setup() -> Option { progress_label: None, phony: false, always: false, + console: false, }, target_name: Utf8PathBuf::from("out"), ninja_args: vec!["out"], @@ -129,6 +132,7 @@ fn ninja_integration_setup() -> Option { progress_label: None, phony: true, always: false, + console: false, }, target_name: Utf8PathBuf::from("action-called.txt"), ninja_args: vec!["say-hello"], @@ -224,6 +228,7 @@ fn errors_when_action_missing() -> Result<()> { progress_label: None, phony: false, always: false, + console: false, }; graph .insert_edge(edge) @@ -271,6 +276,7 @@ fn generate_format_error() -> Result<()> { progress_label: None, phony: false, always: false, + console: false, }; let mut graph = BuildGraph::default(); graph.actions.insert("a".into(), action); @@ -312,6 +318,7 @@ fn serial_graph(command: &str, dependencies: &[&str]) -> Result { progress_label: None, phony: false, always: false, + console: false, }; let mut graph = BuildGraph::default(); graph.actions.insert("a".into(), action); diff --git a/tests/ninja_gen_unit_tests.rs b/tests/ninja_gen_unit_tests.rs index 669c54d04..5bd71584f 100644 --- a/tests/ninja_gen_unit_tests.rs +++ b/tests/ninja_gen_unit_tests.rs @@ -37,6 +37,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: true, always: false, + console: false, }, concat!( "rule a\n", @@ -64,6 +65,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: false, always: false, + console: false, }, concat!( "rule compile\n", @@ -91,6 +93,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: false, always: false, + console: false, }, concat!( "rule b\n", @@ -118,6 +121,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: false, always: false, + console: false, }, concat!( "rule b\n", @@ -145,6 +149,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: false, always: false, + console: false, }, concat!( "rule b\n", @@ -172,6 +177,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: false, always: false, + console: false, }, concat!( "rule b\n", @@ -199,6 +205,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: true, always: false, + console: false, }, concat!( "rule phony\n", @@ -259,6 +266,7 @@ fn generate_multiline_script_snapshot() { progress_label: None, phony: false, always: false, + console: false, }) .expect("test graph output aliases must be unique"); graph.default_targets.push(Utf8PathBuf::from("out")); diff --git a/tests/ninja_snapshot_tests.rs b/tests/ninja_snapshot_tests.rs index e82f27fea..062a8baa5 100644 --- a/tests/ninja_snapshot_tests.rs +++ b/tests/ninja_snapshot_tests.rs @@ -240,7 +240,7 @@ fn multi_command_manifest_ninja_snapshot() -> Result<()> { Ok(()) } -/// Snapshot command-list lowering for isolated and implicit-dependency recipes. +/// Snapshot selected manifest recipes and edge-level Ninja metadata. #[rstest] #[case::isolated_command_list( "tests/data/isolated_command_list.yml", @@ -249,16 +249,30 @@ fn multi_command_manifest_ninja_snapshot() -> Result<()> { "command = ( { _netsuke_background_before=", "} )", ], + &[], )] #[case::implicit_deps( "tests/data/implicit_deps.yml", "implicit_deps_manifest_ninja", &[" | "], + &[], )] -fn command_list_manifest_ninja_snapshot( +#[case::console( + "tests/data/console.yml", + "console_manifest_ninja", + &["build streamed-action:", "build inline-console:", "pool = console"], + &[ + ("streamed-action", true), + ("ordinary-action", false), + ("ordinary-target", false), + ("inline-console", true), + ], +)] +fn manifest_ninja_snapshot( #[case] fixture_path: &str, #[case] snapshot_name: &str, #[case] expected_fragments: &[&str], + #[case] expected_console_bindings: &[(&str, bool)], ) -> Result<()> { let fixture_dir = Dir::open_ambient_dir(env!("CARGO_MANIFEST_DIR"), ambient_authority()) .context("open repository root to read command-list test data")?; @@ -275,7 +289,9 @@ fn command_list_manifest_ninja_snapshot( "expected `{fragment}` in generated Ninja output:\n{ninja_content}" ); } - + for (output, expected) in expected_console_bindings { + assert_console_edge_binding(&ninja_content, output, *expected)?; + } let mut settings = Settings::new(); settings.set_snapshot_path(concat!( env!("CARGO_MANIFEST_DIR"), @@ -288,6 +304,26 @@ fn command_list_manifest_ninja_snapshot( Ok(()) } +/// Assert whether one named edge has an immediately following console binding. +/// +/// # Errors +/// +/// Returns an error when the edge is missing or its binding does not match. +fn assert_console_edge_binding(ninja: &str, output: &str, expected: bool) -> Result<()> { + let lines: Vec<_> = ninja.lines().collect(); + let prefix = format!("build {output}:"); + let edge_index = lines + .iter() + .position(|line| line.starts_with(&prefix)) + .with_context(|| format!("missing Ninja build edge for {output}"))?; + let actual = lines.get(edge_index + 1) == Some(&" pool = console"); + ensure!( + actual == expected, + "edge {output} console binding should be {expected}, generated Ninja:\n{ninja}" + ); + Ok(()) +} + /// Snapshot dependency-only actions as native Ninja phony nodes. #[test] fn dependency_only_manifest_ninja_snapshot() -> Result<()> { diff --git a/tests/serial_dependency_runtime_tests.rs b/tests/serial_dependency_runtime_tests.rs index 62111580b..ba4c99158 100644 --- a/tests/serial_dependency_runtime_tests.rs +++ b/tests/serial_dependency_runtime_tests.rs @@ -50,6 +50,7 @@ fn edge( progress_label: None, phony: false, always: false, + console: false, } } diff --git a/tests/snapshots/ninja/ninja_snapshot_tests__console_manifest_ninja.snap b/tests/snapshots/ninja/ninja_snapshot_tests__console_manifest_ninja.snap new file mode 100644 index 000000000..e6778906b --- /dev/null +++ b/tests/snapshots/ninja/ninja_snapshot_tests__console_manifest_ninja.snap @@ -0,0 +1,25 @@ +--- +source: tests/ninja_snapshot_tests.rs +expression: ninja_content +--- +rule 04ca8490eedc2a14f7ea86f319edd5b7ea47a9b1dc3deab48f610cfba9a50fe0 + command = echo inline-console + +rule 409e0ba0938d3fa40f7344fdd2f33e631f5b4ec8b51527e101bd8f6f29851b1c + command = echo streamed-action + +rule 942a9563150dd5319d3478ac8b180f7d234b732e102e1a2e1f190dbbc53562e0 + command = echo ordinary-target + +rule aed5f83a13f8763315f10af13630dd88eccbc70c46a8f0a4c2ac132e7f6f2858 + command = echo ordinary-action + +build inline-console: 04ca8490eedc2a14f7ea86f319edd5b7ea47a9b1dc3deab48f610cfba9a50fe0 + pool = console + +build ordinary-action: aed5f83a13f8763315f10af13630dd88eccbc70c46a8f0a4c2ac132e7f6f2858 + +build ordinary-target: 942a9563150dd5319d3478ac8b180f7d234b732e102e1a2e1f190dbbc53562e0 + +build streamed-action: 409e0ba0938d3fa40f7344fdd2f33e631f5b4ec8b51527e101bd8f6f29851b1c + pool = console diff --git a/tests/ui/build_edge_console_compile_pass.rs b/tests/ui/build_edge_console_compile_pass.rs new file mode 100644 index 000000000..bb402ecb8 --- /dev/null +++ b/tests/ui/build_edge_console_compile_pass.rs @@ -0,0 +1,20 @@ +//! Compile the public console scheduling flag as an external crate would. + +use netsuke::ir::{BuildEdge, DependencyOrder}; + +/// Construct a public build edge that opts into Ninja's console pool. +#[must_use] +pub fn console_edge() -> BuildEdge { + BuildEdge { + action_id: "lint".into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec!["lint".into()], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + console: true, + } +} From cf1f5002fd62d5b4954f16fc204a1527c32f5d17 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 25 Oct 2026 19:32:23 +0100 Subject: [PATCH 03/27] Fix console pool gate regressions (#946) Clear inherited console pools from ordinary edges and preserve localized validation diagnostics. Restructure runtime and shell syntax tests to satisfy the code health and module layout contracts. --- ...elect-ninja-console-pool-per-build-edge.md | 11 +- docs/developers-guide.md | 6 +- docs/users-guide.md | 3 +- locales/hi/messages.ftl | 2 +- locales/pt-BR/messages.ftl | 4 +- locales/pt-PT/messages.ftl | 4 +- locales/sv/messages.ftl | 4 +- locales/th/messages.ftl | 4 +- locales/tr/messages.ftl | 4 +- locales/vi/messages.ftl | 4 +- src/ir/graph/error.rs | 15 +- src/manifest/mod.rs | 20 ++- src/ninja_gen/display_edge.rs | 6 + src/ninja_gen/dyndep/render.rs | 4 + src/ninja_gen/dyndep/tests/console.rs | 61 ++++++++ src/ninja_gen/edge_metadata.rs | 3 + src/ninja_gen/mod.rs | 1 + src/ninja_gen/property_tests/mod.rs | 1 + .../{shell_command.rs => command_syntax.rs} | 2 +- src/ninja_gen/tests/console.rs | 36 +++++ src/ninja_gen/tests/mod.rs | 2 +- tests/ast_tests/console.rs | 28 ++++ tests/ast_tests/dependency_only.rs | 19 ++- tests/bdd/steps/ninja.rs | 21 +++ tests/console_pool_runtime_tests.rs | 109 +-------------- .../console_pool_runtime_tests/exclusivity.rs | 130 ++++++++++++++++++ tests/features/ninja.feature | 3 +- tests/ir_tests.rs | 4 +- tests/ninja_snapshot_tests.rs | 8 +- tests/ui/build_edge_console_compile_pass.rs | 30 +++- 30 files changed, 404 insertions(+), 145 deletions(-) rename src/ninja_gen/tests/{shell_command.rs => command_syntax.rs} (83%) create mode 100644 tests/console_pool_runtime_tests/exclusivity.rs diff --git a/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md b/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md index b809dc891..587ce0aed 100644 --- a/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md +++ b/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md @@ -37,7 +37,10 @@ owner. - Add a default-false `console` Boolean to manifest actions and targets, and carry it as `BuildEdge.console`. - Emit an edge-level `pool = console` binding only for selected user edges. - Synthetic serial gates and dyndep sidecars do not inherit the binding. + When an action rule declares `pool = console`, emit an empty edge-level + `pool =` binding on its ordinary edges so they do not inherit console + scheduling. Synthetic serial gates and dyndep sidecars do not inherit either + binding. - Keep `Action.pool` and action hashing unchanged. A pre-existing `Action.pool: console` is accepted as redundant; a different custom pool conflicts with console selection and is rejected by the generator. @@ -47,6 +50,12 @@ owner. - Validate these rules before both direct Ninja generation and dyndep-bundle generation, preserving the existing missing-action diagnostic path. +Adding the field to the public `Target` struct breaks downstream Rust callers +that construct it with a struct literal. The package currently declares version +`0.1.0-beta4`, and this source break is accepted for the next pre-release API: +callers must provide `console`. The default only preserves manifest +compatibility through Serde. + Ninja runs console-pool edges one at a time in one invocation. Output from other edges is held while a console edge runs. Netsuke forwards Ninja output as it reads it outside JSON mode, but it does not allocate a PTY; recipes see a diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 90b9ba257..502a3b117 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -6506,12 +6506,14 @@ The manifest `console` Boolean lowers to `BuildEdge.console`. It is scheduling metadata owned by the edge because two targets can share one action and rule while only one target needs streamed output. `Action.pool` remains unchanged, so console selection does not alter action identity or action deduplication. -`src/ninja_gen/display_edge.rs` emits `pool = console` on that edge, and shared +`src/ninja_gen/display_edge.rs` emits `pool = console` on that edge. If a +shared action rule already declares `pool = console`, ordinary edges get an +empty edge-level `pool =` binding to clear that inherited selection. Shared console-scheduling validation runs before both direct Ninja generation and dyndep-bundle generation. That validation rejects dependency-only edges and custom action pools that conflict with `console`; it accepts an existing `Action.pool` value of `console`. Synthetic serial gates and dyndep sidecars -never inherit the binding. +never inherit either binding. `src/ir/cycle/detector.rs::CycleDetector::visit_known_edge` traverses `inputs`, `implicit_deps`, and `order_only_deps` when detecting cycles. Order-only diff --git a/docs/users-guide.md b/docs/users-guide.md index 6a0f8467e..86da35812 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -612,7 +612,8 @@ A target supports these fields: - `console`: selects Ninja's serial console pool for this executable action or target. It defaults to `false` and is unavailable to dependency-only aggregates. Netsuke rejects a conflicting custom action pool; an action pool - already named `console` is redundant and accepted. + already named `console` is accepted for a console edge. Other edges sharing + that rule have the inherited console pool cleared. - `description`: an optional human-readable summary of the public operation the target performs. It is discovery metadata shown by `netsuke help targets`; it never replaces a referenced rule's `description` diff --git a/locales/hi/messages.ftl b/locales/hi/messages.ftl index 6ebd4cd5a..fe3f8376e 100644 --- a/locales/hi/messages.ftl +++ b/locales/hi/messages.ftl @@ -197,7 +197,7 @@ ir.action_serialisation = क्रिया का क्रमांकन न ir.invalid_command = आदेश में अमान्य प्रक्षेपण: { $snippet }। # Ninja निर्माण की त्रुटियाँ। -ninja_gen.missing_action = किसी बिल्ड कोर द्वारा संदर्भित क्रिया “{ $id }” अनुपस्थित है। +ninja_gen.missing_action = किसी बिल्ड एज द्वारा संदर्भित क्रिया “{ $id }” अनुपस्थित है। ninja_gen.console_dependency_only = बिल्ड एज ‘{ $output }’ कंसोल पूल नहीं चुन सकता, क्योंकि इसकी क्रिया केवल निर्भरताएँ बताती है। ninja_gen.console_pool_conflict = बिल्ड एज ‘{ $output }’ कंसोल पूल चुनता है, लेकिन इसकी क्रिया कस्टम पूल ‘{ $pool }’ का अनुरोध करती है। ninja_gen.format = Ninja मैनिफ़ेस्ट का निर्गम स्वरूपित नहीं किया जा सका। diff --git a/locales/pt-BR/messages.ftl b/locales/pt-BR/messages.ftl index e220acc7d..02456619e 100644 --- a/locales/pt-BR/messages.ftl +++ b/locales/pt-BR/messages.ftl @@ -199,8 +199,8 @@ ir.invalid_command = Interpolação de comando inválida: { $snippet }. # Erros de geração do Ninja. ninja_gen.missing_action = Falta a ação "{ $id }" referenciada por uma aresta de build. -ninja_gen.console_dependency_only = A aresta de compilação ‘{ $output }’ não pode selecionar o pool do console porque sua ação declara apenas dependências. -ninja_gen.console_pool_conflict = A aresta de compilação ‘{ $output }’ seleciona o pool do console, mas sua ação solicita o pool personalizado ‘{ $pool }’. +ninja_gen.console_dependency_only = A aresta de compilação "{ $output }" não pode selecionar o pool do console porque sua ação declara apenas dependências. +ninja_gen.console_pool_conflict = A aresta de compilação "{ $output }" seleciona o pool do console, mas sua ação solicita o pool personalizado "{ $pool }". ninja_gen.format = Não foi possível formatar a saída do manifesto do Ninja. ninja_gen.dyndep_files_required = Esta operação requer um pacote Ninja gerado; use `netsuke build`, `netsuke clean` ou `netsuke generate` para materializar os arquivos dyndep. ninja_gen.reserved_output_path = O caminho '{ $path }' está reservado para o estado de dependências seriais do Netsuke. diff --git a/locales/pt-PT/messages.ftl b/locales/pt-PT/messages.ftl index 567ca9edb..448f12158 100644 --- a/locales/pt-PT/messages.ftl +++ b/locales/pt-PT/messages.ftl @@ -199,8 +199,8 @@ ir.invalid_command = Interpolação de comando inválida: { $snippet }. # Erros de geração do Ninja. ninja_gen.missing_action = Falta a ação «{ $id }» referenciada por uma aresta de compilação. -ninja_gen.console_dependency_only = A aresta de compilação ‘{ $output }’ não pode selecionar o conjunto da consola porque a sua ação só declara dependências. -ninja_gen.console_pool_conflict = A aresta de compilação ‘{ $output }’ seleciona o conjunto da consola, mas a sua ação pede o conjunto personalizado ‘{ $pool }’. +ninja_gen.console_dependency_only = A aresta de compilação «{ $output }» não pode selecionar o conjunto da consola porque a sua ação só declara dependências. +ninja_gen.console_pool_conflict = A aresta de compilação «{ $output }» seleciona o conjunto da consola, mas a sua ação pede o conjunto personalizado «{ $pool }». ninja_gen.format = Não foi possível formatar a saída do manifesto Ninja. ninja_gen.dyndep_files_required = Esta operação requer um pacote Ninja gerado; utilize `netsuke build`, `netsuke clean` ou `netsuke generate` para materializar os ficheiros dyndep. ninja_gen.reserved_output_path = O caminho '{ $path }' está reservado para o estado de dependências seriais do Netsuke. diff --git a/locales/sv/messages.ftl b/locales/sv/messages.ftl index 3b0212fb2..a187be934 100644 --- a/locales/sv/messages.ftl +++ b/locales/sv/messages.ftl @@ -198,8 +198,8 @@ ir.invalid_command = Ogiltig interpolering i kommandot: { $snippet }. # Fel vid generering av Ninja. ninja_gen.missing_action = Åtgärden ”{ $id }” som en byggbåge hänvisar till saknas. -ninja_gen.console_dependency_only = Byggkanten ”{ $output }” kan inte välja konsolpoolen eftersom åtgärden bara innehåller beroenden. -ninja_gen.console_pool_conflict = Byggkanten ”{ $output }” väljer konsolpoolen, men åtgärden begär den anpassade poolen ”{ $pool }”. +ninja_gen.console_dependency_only = Byggbågen ”{ $output }” kan inte välja konsolpoolen eftersom åtgärden bara innehåller beroenden. +ninja_gen.console_pool_conflict = Byggbågen ”{ $output }” väljer konsolpoolen, men åtgärden begär den anpassade poolen ”{ $pool }”. ninja_gen.format = Ninja-manifestets utdata kunde inte formateras. ninja_gen.dyndep_files_required = Den här åtgärden kräver ett genererat Ninja-paket; använd `netsuke build`, `netsuke clean` eller `netsuke generate` för att materialisera dyndep-filerna. ninja_gen.reserved_output_path = Sökvägen '{ $path }' är reserverad för Netsukes seriella beroendetillstånd. diff --git a/locales/th/messages.ftl b/locales/th/messages.ftl index 0e81c2f12..31c5d2505 100644 --- a/locales/th/messages.ftl +++ b/locales/th/messages.ftl @@ -198,8 +198,8 @@ ir.invalid_command = การแทรกค่าในคำสั่งไ # ข้อผิดพลาดในการสร้างไฟล์ Ninja ninja_gen.missing_action = ไม่มีการกระทำ “{ $id }” ที่เส้นเชื่อมของการสร้างอ้างถึง -ninja_gen.console_dependency_only = ขอบเขตการสร้าง ‘{ $output }’ เลือกพูลคอนโซลไม่ได้ เพราะการกระทำมีเฉพาะการพึ่งพาเท่านั้น -ninja_gen.console_pool_conflict = ขอบเขตการสร้าง ‘{ $output }’ เลือกพูลคอนโซล แต่การกระทำร้องขอพูลที่กำหนดเอง ‘{ $pool }’ +ninja_gen.console_dependency_only = เส้นเชื่อมของการสร้าง “{ $output }” เลือกพูลคอนโซลไม่ได้ เพราะการกระทำมีเฉพาะการพึ่งพาเท่านั้น +ninja_gen.console_pool_conflict = เส้นเชื่อมของการสร้าง “{ $output }” เลือกพูลคอนโซล แต่การกระทำร้องขอพูลที่กำหนดเอง “{ $pool }” ninja_gen.format = จัดรูปแบบผลลัพธ์ของไฟล์รายการ Ninja ไม่สำเร็จ ninja_gen.dyndep_files_required = การดำเนินการนี้ต้องใช้บันเดิล Ninja ที่สร้างขึ้น ให้ใช้ `netsuke build`, `netsuke clean` หรือ `netsuke generate` เพื่อทำให้ไฟล์ dyndep พร้อมใช้งาน ninja_gen.reserved_output_path = เส้นทาง '{ $path }' สงวนไว้สำหรับสถานะการขึ้นต่อกันแบบลำดับของ Netsuke diff --git a/locales/tr/messages.ftl b/locales/tr/messages.ftl index 1d48bdf39..694576617 100644 --- a/locales/tr/messages.ftl +++ b/locales/tr/messages.ftl @@ -198,8 +198,8 @@ ir.invalid_command = Komutta geçersiz yerleştirme: { $snippet }. # Ninja üretimi hataları. ninja_gen.missing_action = Bir derleme kenarının başvurduğu "{ $id }" eylemi eksik. -ninja_gen.console_dependency_only = ‘{ $output }’ derleme kenarı, eylemi yalnızca bağımlılıklardan oluştuğu için konsol havuzunu seçemez. -ninja_gen.console_pool_conflict = ‘{ $output }’ derleme kenarı konsol havuzunu seçiyor, ancak eylemi özel ‘{ $pool }’ havuzunu istiyor. +ninja_gen.console_dependency_only = "{ $output }" derleme kenarı, eylemi yalnızca bağımlılıklardan oluştuğu için konsol havuzunu seçemez. +ninja_gen.console_pool_conflict = "{ $output }" derleme kenarı konsol havuzunu seçiyor, ancak eylemi özel "{ $pool }" havuzunu istiyor. ninja_gen.format = Ninja bildiriminin çıktısı biçimlendirilemedi. ninja_gen.dyndep_files_required = Bu işlem oluşturulmuş bir Ninja paketi gerektiriyor; dyndep dosyalarını oluşturmak için `netsuke build`, `netsuke clean` veya `netsuke generate` kullanın. ninja_gen.reserved_output_path = '{ $path }' yolu Netsuke'nin seri bağımlılık durumu için ayrılmıştır. diff --git a/locales/vi/messages.ftl b/locales/vi/messages.ftl index 9068844da..5e2d14a17 100644 --- a/locales/vi/messages.ftl +++ b/locales/vi/messages.ftl @@ -198,8 +198,8 @@ ir.invalid_command = Nội suy không hợp lệ trong lệnh: { $snippet }. # Lỗi khi tạo tệp Ninja. ninja_gen.missing_action = Thiếu hành động “{ $id }” mà một cạnh dựng tham chiếu. -ninja_gen.console_dependency_only = Cạnh bản dựng ‘{ $output }’ không thể chọn nhóm console vì tác vụ của nó chỉ chứa các phụ thuộc. -ninja_gen.console_pool_conflict = Cạnh bản dựng ‘{ $output }’ chọn nhóm console, nhưng tác vụ yêu cầu nhóm tùy chỉnh ‘{ $pool }’. +ninja_gen.console_dependency_only = Cạnh dựng “{ $output }” không thể chọn nhóm console vì hành động của nó chỉ chứa các phụ thuộc. +ninja_gen.console_pool_conflict = Cạnh dựng “{ $output }” chọn nhóm console, nhưng hành động yêu cầu nhóm tùy chỉnh “{ $pool }”. ninja_gen.format = Không định dạng được đầu ra của tệp kê khai Ninja. ninja_gen.dyndep_files_required = Thao tác này yêu cầu các tệp Ninja dyndep đã tạo; hãy dùng `netsuke build`, `netsuke clean` hoặc `netsuke generate` để hiện thực hóa chúng. ninja_gen.reserved_output_path = Đường dẫn '{ $path }' được dành riêng cho trạng thái phụ thuộc tuần tự của Netsuke. diff --git a/src/ir/graph/error.rs b/src/ir/graph/error.rs index 835019672..e2b9c3e8d 100644 --- a/src/ir/graph/error.rs +++ b/src/ir/graph/error.rs @@ -3,10 +3,21 @@ //! Keeps recoverable validation failures separate from the graph containers so //! callers can inspect stable IR diagnostics without coupling to storage. -use crate::localization::LocalizedMessage; +use crate::localization::{self, LocalizedMessage, keys}; use camino::Utf8PathBuf; use thiserror::Error; +/// Render a manifest validation diagnostic in the active locale when possible. +fn invalid_manifest_message(message: &'static str) -> String { + if message == crate::ast::CONSOLE_DEPENDENCY_ONLY_ERROR { + localization::message(keys::NINJA_GEN_CONSOLE_DEPENDENCY_ONLY) + .with_arg("output", "dependency-only entry") + .to_string() + } else { + message.to_owned() + } +} + /// Errors produced during IR generation. /// /// Each variant documents a distinct validation failure encountered while @@ -39,7 +50,7 @@ use thiserror::Error; #[derive(Debug, Error)] pub enum IrGenError { /// Raised when a directly deserialized manifest violates recipe rules. - #[error("{message}")] + #[error("{}", invalid_manifest_message(.message))] InvalidManifest { /// Stable schema diagnostic identifying the violated recipe rule. message: &'static str, diff --git a/src/manifest/mod.rs b/src/manifest/mod.rs index a2e09f5ec..9e5c5d35e 100644 --- a/src/manifest/mod.rs +++ b/src/manifest/mod.rs @@ -153,6 +153,14 @@ struct ExpansionRequest<'name, 'a, 'stage> { on_stage: &'a mut Option<&'stage mut dyn FnMut(ManifestLoadStage)>, } +/// Localize recipe validation errors that have a matching user-facing message. +fn recipe_validation_message(detail: &'static str) -> Option { + (detail == crate::ast::CONSOLE_DEPENDENCY_ONLY_ERROR).then(|| { + localization::message(keys::NINJA_GEN_CONSOLE_DEPENDENCY_ONLY) + .with_arg("output", "dependency-only entry") + }) +} + /// Parse, render, and validate a manifest with injected loading boundaries. /// /// Render Jinja values, anchor relative `glob()` patterns at `manifest_root`, @@ -262,12 +270,14 @@ fn expand_and_render(request: ExpansionRequest<'_, '_, '_>) -> Result { pub(crate) action_name: &'a str, /// Whether the action sets `restat`, suppressing the edge-level override. pub(crate) action_restat: bool, + /// Pool selected by the shared action rule, if any. + pub(crate) action_pool: Option<&'a str>, /// Dependencies rendered after `|`, either the edge's implicit deps or lowered serial gates. pub(crate) implicit_deps: &'a [Utf8PathBuf], /// Optional localized inline-recipe progress description, escaped for Ninja. @@ -41,6 +43,10 @@ impl Display for DisplayEdge<'_> { write_kv!(output, "description", self.description); if self.edge.console { writeln!(output, " pool = console")?; + } else if self.action_pool == Some("console") { + // Rule bindings are inherited by edges, so clear a rule-level + // console pool on edges that did not opt in themselves. + writeln!(output, " pool =")?; } writeln!(output) } diff --git a/src/ninja_gen/dyndep/render.rs b/src/ninja_gen/dyndep/render.rs index d9dd4ffc7..690d66df3 100644 --- a/src/ninja_gen/dyndep/render.rs +++ b/src/ninja_gen/dyndep/render.rs @@ -55,6 +55,7 @@ impl EdgeRenderer<'_> { let rendered_action = RenderedAction { name: metadata.action_name, restat: metadata.restat, + pool: metadata.pool, }; let rendered_edge = RenderedEdge { action: rendered_action, @@ -91,6 +92,7 @@ impl EdgeRenderer<'_> { edge, action_name: rendered_edge.action.name, action_restat: rendered_edge.action.restat, + action_pool: rendered_edge.action.pool, implicit_deps, description: rendered_edge.description, } @@ -115,4 +117,6 @@ struct RenderedAction<'a> { name: &'a str, /// Preserve whether Ninja should restat this edge after execution. restat: bool, + /// Pool selected by the shared action, if any. + pool: Option<&'a str>, } diff --git a/src/ninja_gen/dyndep/tests/console.rs b/src/ninja_gen/dyndep/tests/console.rs index 21ab95a62..137df38c2 100644 --- a/src/ninja_gen/dyndep/tests/console.rs +++ b/src/ninja_gen/dyndep/tests/console.rs @@ -1,7 +1,9 @@ //! Tests for console selection across staged serial dependency bundles. use super::{generate_bundle, graph_with_edge, serial_edge}; +use crate::ir::{BuildEdge, DependencyOrder}; use anyhow::{Result, ensure}; +use camino::Utf8PathBuf; /// Keep console scheduling on the user edge, away from synthetic gates and sidecars. #[test] @@ -45,3 +47,62 @@ fn console_serial_parent_does_not_mark_gates_or_sidecars() -> Result<()> { ); Ok(()) } + +/// Clear a shared console rule pool from ordinary edges in a dyndep bundle. +#[test] +fn ordinary_dyndep_edge_clears_a_shared_console_action_pool() -> Result<()> { + let mut selected = serial_edge("all", &["check-fmt", "lint"]); + selected.console = true; + let mut graph = graph_with_edge(selected); + graph.actions.get_mut("a").expect("test action exists").pool = Some("console".into()); + graph + .insert_edge(BuildEdge { + action_id: "a".into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec![Utf8PathBuf::from("ordinary")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + console: false, + }) + .expect("test graph output aliases must be unique"); + + let bundle = generate_bundle(&graph)?; + let file = bundle.build_file(); + let lines: Vec<_> = file.lines().collect(); + let selected_index = lines + .iter() + .position(|line| line.starts_with("build all: a |")) + .expect("selected serial edge must be emitted"); + ensure!( + lines + .iter() + .skip(selected_index + 1) + .take_while(|line| line.starts_with(" ")) + .any(|line| *line == " pool = console"), + "selected serial edge must retain console scheduling:\n{file}" + ); + ensure!( + file.contains("build ordinary: a\n pool =\n\n"), + "ordinary edge must clear the action rule's console pool:\n{file}" + ); + let synthetic_edge_selects_console = lines + .iter() + .enumerate() + .filter(|(_, line)| line.starts_with("build .netsuke/serial/")) + .any(|(index, _)| { + lines + .iter() + .skip(index + 1) + .take_while(|line| line.starts_with(" ")) + .any(|line| *line == " pool = console") + }); + ensure!( + !synthetic_edge_selects_console, + "synthetic serial gates must not select the console pool:\n{file}" + ); + Ok(()) +} diff --git a/src/ninja_gen/edge_metadata.rs b/src/ninja_gen/edge_metadata.rs index 27ae1e6ff..82fe87a1d 100644 --- a/src/ninja_gen/edge_metadata.rs +++ b/src/ninja_gen/edge_metadata.rs @@ -16,6 +16,8 @@ pub(super) struct EdgeMetadata<'a> { pub(super) action_name: &'a str, /// Whether Ninja should remove the output before running the recipe. pub(super) restat: bool, + /// Pool selected by the shared action, if any. + pub(super) pool: Option<&'a str>, /// Optional, validated edge-level description with Ninja dollars escaped. pub(super) description: Option, } @@ -61,6 +63,7 @@ pub(super) fn for_edge<'a>( Ok(EdgeMetadata { action_name, restat: action.restat, + pool: action.pool.as_deref(), description, }) } diff --git a/src/ninja_gen/mod.rs b/src/ninja_gen/mod.rs index 193fdd668..4273f0874 100644 --- a/src/ninja_gen/mod.rs +++ b/src/ninja_gen/mod.rs @@ -193,6 +193,7 @@ pub(crate) fn generate_into_with_shell( edge, action_name: metadata.action_name, action_restat: metadata.restat, + action_pool: metadata.pool, implicit_deps: &edge.implicit_deps, description: metadata.description.as_ref(), } diff --git a/src/ninja_gen/property_tests/mod.rs b/src/ninja_gen/property_tests/mod.rs index 4f392c787..3018a7edb 100644 --- a/src/ninja_gen/property_tests/mod.rs +++ b/src/ninja_gen/property_tests/mod.rs @@ -67,6 +67,7 @@ fn format_edge(edge: &BuildEdge) -> String { edge, action_name: &edge.action_id, action_restat: false, + action_pool: None, implicit_deps: &edge.implicit_deps, description: None, } diff --git a/src/ninja_gen/tests/shell_command.rs b/src/ninja_gen/tests/command_syntax.rs similarity index 83% rename from src/ninja_gen/tests/shell_command.rs rename to src/ninja_gen/tests/command_syntax.rs index 61bf2f68e..bd994650d 100644 --- a/src/ninja_gen/tests/shell_command.rs +++ b/src/ninja_gen/tests/command_syntax.rs @@ -1,4 +1,4 @@ -//! Tests for shell command text accepted by Ninja generation. +//! Test shell command text accepted by Ninja generation. use super::NamedAction; diff --git a/src/ninja_gen/tests/console.rs b/src/ninja_gen/tests/console.rs index 27e51ec5f..7246cb800 100644 --- a/src/ninja_gen/tests/console.rs +++ b/src/ninja_gen/tests/console.rs @@ -46,6 +46,42 @@ fn console_pool_is_edge_level_and_keeps_shared_action_rules() -> Result<()> { Ok(()) } +/// Clear rule-level console scheduling from an edge without the opt-in. +#[test] +fn ordinary_edge_clears_a_shared_console_action_pool() -> Result<()> { + let mut graph = BuildGraph::default(); + let mut action = command_action("echo ready".into()); + action.pool = Some("console".into()); + graph.actions.insert("shared".into(), action); + for (output, console) in [("console.out", true), ("ordinary.out", false)] { + graph + .insert_edge(BuildEdge { + action_id: "shared".into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec![Utf8PathBuf::from(output)], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + console, + }) + .expect("test graph output aliases must be unique"); + } + + let ninja = generate(&graph)?; + ensure!( + ninja.contains("pool = console\n\nbuild console.out: shared\n pool = console\n"), + "selected edge must retain console scheduling:\n{ninja}" + ); + ensure!( + ninja.contains("build ordinary.out: shared\n pool =\n\n"), + "ordinary edge must clear the action rule's console pool:\n{ninja}" + ); + Ok(()) +} + /// Accept the redundant combination of an edge flag and `pool = console` metadata. #[test] fn console_pool_accepts_matching_action_pool() -> Result<()> { diff --git a/src/ninja_gen/tests/mod.rs b/src/ninja_gen/tests/mod.rs index 268f6a51d..c20b72faf 100644 --- a/src/ninja_gen/tests/mod.rs +++ b/src/ninja_gen/tests/mod.rs @@ -342,10 +342,10 @@ fn generate_posix(graph: &BuildGraph) -> Result { } mod bash_binding; +mod command_syntax; mod console; mod isolated_command_list; #[path = "legacy_shell.rs"] mod power_shell; #[path = "shell_isolated.rs"] mod power_shell_isolated; -mod shell_command; diff --git a/tests/ast_tests/console.rs b/tests/ast_tests/console.rs index 581c623d3..9e32fdbfb 100644 --- a/tests/ast_tests/console.rs +++ b/tests/ast_tests/console.rs @@ -4,6 +4,7 @@ use anyhow::{Context, Result, ensure}; use rstest::rstest; use super::support::parse_manifest; +use netsuke::manifest::ManifestError; /// Default target console scheduling to false and accept either explicit value. #[rstest] @@ -28,6 +29,33 @@ fn target_console_defaults_and_overrides( Ok(()) } +/// Preserve the parse diagnostic path while localizing dependency-only console errors. +#[test] +fn dependency_only_console_manifest_error_is_localized() -> Result<()> { + let _locale = test_support::localizer::locale_localizer("fr"); + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: all + deps: [lint] + console: true + "#; + let error = parse_manifest(yaml).expect_err("dependency-only console must be rejected"); + ensure!( + matches!( + error.downcast_ref::(), + Some(ManifestError::Parse { .. }) + ), + "manifest validation should retain the parse diagnostic path: {error:#}" + ); + let rendered = format!("{error:#}"); + ensure!( + rendered.contains("arête de compilation"), + "dependency-only console diagnostic should use the French catalogue: {rendered}" + ); + Ok(()) +} + /// Keep contention fields unknown until RFC 0024 defines a manifest schema. #[test] fn contention_remains_an_unknown_manifest_field() { diff --git a/tests/ast_tests/dependency_only.rs b/tests/ast_tests/dependency_only.rs index 0030b7aa1..6105238c1 100644 --- a/tests/ast_tests/dependency_only.rs +++ b/tests/ast_tests/dependency_only.rs @@ -4,10 +4,10 @@ use anyhow::{Context, Result, ensure}; use netsuke::localization::{self, keys}; use netsuke::{ ast::{CommandIsolation, NetsukeManifest, Recipe, StringOrList}, - ir::BuildGraph, + ir::{BuildGraph, IrGenError}, }; use rstest::rstest; -use test_support::display_error_chain; +use test_support::{display_error_chain, localizer::locale_localizer}; use super::support::parse_manifest; @@ -236,7 +236,7 @@ fn dependency_only_console_entries_are_rejected(#[case] sections: &str) -> Resul parse_manifest(&yaml).expect_err("dependency-only entries cannot use the console pool"); let chain = display_error_chain(error.as_ref()); ensure!( - chain.contains("console cannot be enabled for dependency-only entries"), + chain.contains("console pool") && chain.contains("dependency-only"), "unexpected console dependency-only diagnostic: {chain}" ); Ok(()) @@ -245,6 +245,7 @@ fn dependency_only_console_entries_are_rejected(#[case] sections: &str) -> Resul /// Preserve the console-specific validation diagnostic during direct IR lowering. #[test] fn direct_ir_lowering_preserves_console_dependency_only_diagnostic() -> Result<()> { + let _locale = locale_localizer("fr"); let yaml = concat!( "netsuke_version: '1.0.0'\n", "targets:\n", @@ -257,8 +258,16 @@ fn direct_ir_lowering_preserves_console_dependency_only_diagnostic() -> Result<( let error = BuildGraph::from_manifest(&manifest) .expect_err("console scheduling on an aggregate should fail IR lowering"); ensure!( - error.to_string() == "console cannot be enabled for dependency-only entries", - "direct AST lowering should preserve the console diagnostic: {error}" + matches!( + &error, + IrGenError::InvalidManifest { message } + if *message == "console cannot be enabled for dependency-only entries" + ), + "direct AST lowering should preserve the console diagnostic code: {error}" + ); + ensure!( + error.to_string().contains("arête de compilation"), + "direct AST lowering should localize the console diagnostic: {error}" ); Ok(()) } diff --git a/tests/bdd/steps/ninja.rs b/tests/bdd/steps/ninja.rs index f6495d3ec..a8d23baf0 100644 --- a/tests/bdd/steps/ninja.rs +++ b/tests/bdd/steps/ninja.rs @@ -203,6 +203,27 @@ fn ninja_contains(world: &TestWorld, fragment: &str) -> Result<()> { ) } +/// Verify that one named edge has its own console-pool binding. +#[then("the Ninja edge {output:string} has a console pool binding")] +fn ninja_edge_has_console_pool_binding(world: &TestWorld, output: &str) -> Result<()> { + let ninja = get_ninja_content(world)?; + let lines: Vec<_> = ninja.lines().collect(); + let prefix = format!("build {output}:"); + let edge_index = lines + .iter() + .position(|line| line.starts_with(&prefix)) + .with_context(|| format!("missing Ninja build edge for {output}"))?; + ensure!( + lines + .iter() + .skip(edge_index + 1) + .take_while(|line| line.starts_with(" ")) + .any(|line| *line == " pool = console"), + "Ninja edge {output} has no console pool binding:\n{ninja}" + ); + Ok(()) +} + #[then("shlex splitting command {index:usize} yields {tokens:string}")] fn ninja_command_tokens(world: &TestWorld, index: usize, tokens: &str) -> Result<()> { let ninja = get_ninja_content(world)?; diff --git a/tests/console_pool_runtime_tests.rs b/tests/console_pool_runtime_tests.rs index 47861ea6a..0e5471b5e 100644 --- a/tests/console_pool_runtime_tests.rs +++ b/tests/console_pool_runtime_tests.rs @@ -275,110 +275,5 @@ fn console_output_reaches_piped_stdout_before_the_recipe_exits() -> Result<()> { Ok(()) } -/// Keep two console recipes from overlapping under a parallel Ninja job limit. -#[test] -fn console_edges_are_exclusive_with_parallel_jobs_enabled() -> Result<()> { - let Some(workspace) = runtime_workspace() else { - return Ok(()); - }; - let root = workspace_dir(&workspace)?; - let first_release = Utf8PathBuf::from(".release-one"); - let second_release = Utf8PathBuf::from(".release-two"); - root.write( - "Netsukefile", - concat!( - "netsuke_version: \"1.0.0\"\n", - "targets:\n", - " - name: one\n", - " command: >-\n", - " mkdir .console-running 2>/dev/null || { echo COLLISION >&2; exit 1; };\n", - " printf 'START:one\\n';\n", - " while [ ! -f .release-one ]; do sleep 0.02; done;\n", - " rmdir .console-running; touch one; printf 'DONE:one\\n'\n", - " console: true\n", - " - name: two\n", - " command: >-\n", - " mkdir .console-running 2>/dev/null || { echo COLLISION >&2; exit 1; };\n", - " printf 'START:two\\n';\n", - " while [ ! -f .release-two ]; do sleep 0.02; done;\n", - " rmdir .console-running; touch two; printf 'DONE:two\\n'\n", - " console: true\n", - ), - ) - .context("write exclusive console manifest")?; - - let mut command = netsuke_build(&workspace, &["--jobs", "2", "build", "one", "two"])?; - let child = command - .spawn() - .context("spawn Netsuke console exclusivity build")?; - let mut cleanup = ChildCleanup::new( - child, - root.try_clone()?, - vec![first_release.clone(), second_release.clone()], - ); - let stdout = cleanup - .child - .stdout - .take() - .context("Netsuke child should expose stdout")?; - let (output, reader) = spawn_line_reader(stdout); - - let first_start = wait_for_marker(&output, "START:")?; - let first_output = parse_start_output(&first_start)?; - release_edge(&root, &first_output)?; - let second_start = wait_for_marker(&output, "START:")?; - let second_output = parse_start_output(&second_start)?; - ensure!( - first_output != second_output, - "the two console edges should each start once" - ); - release_edge(&root, &second_output)?; - finish_successfully(&mut cleanup)?; - reader - .join() - .map_err(|_| anyhow::anyhow!("stdout forwarding thread panicked"))?; - for target in ["one", "two"] { - ensure!( - root.exists(target), - "console edge {target} should complete after release" - ); - } - ensure!( - !root.exists(".console-running"), - "the shared sentinel should be removed after both recipes finish" - ); - Ok(()) -} - -/// Release one exclusivity-test recipe by its declared output name. -/// -/// # Errors -/// -/// Returns an error for unexpected output names or failed release-file writes. -fn release_edge(workspace: &Dir, output: &str) -> Result<()> { - ensure!( - matches!(output, "one" | "two"), - "unexpected output marker {output}" - ); - workspace - .write(format!(".release-{output}"), b"release") - .with_context(|| format!("release console edge {output}"))?; - Ok(()) -} - -/// Extract and validate the target name printed by the shared recipe. -/// -/// # Errors -/// -/// Returns an error when the marker is absent or does not name a test output. -fn parse_start_output(line: &str) -> Result { - let output = line - .split_once("START:") - .map(|(_, output)| output.trim()) - .context("console recipe start marker should include its output")?; - ensure!( - matches!(output, "one" | "two"), - "unexpected console output {output}" - ); - Ok(output.to_owned()) -} +#[path = "console_pool_runtime_tests/exclusivity.rs"] +mod exclusivity; diff --git a/tests/console_pool_runtime_tests/exclusivity.rs b/tests/console_pool_runtime_tests/exclusivity.rs new file mode 100644 index 000000000..257b62494 --- /dev/null +++ b/tests/console_pool_runtime_tests/exclusivity.rs @@ -0,0 +1,130 @@ +//! Verify that Ninja serializes independent console-pool recipes. + +use super::{ + ChildCleanup, finish_successfully, netsuke_build, runtime_workspace, spawn_line_reader, + wait_for_marker, workspace_dir, +}; +use anyhow::{Context, Result, ensure}; +use camino::Utf8PathBuf; +use cap_std::fs_utf8::Dir; + +/// Keep two console recipes from overlapping under a parallel Ninja job limit. +#[test] +fn console_edges_are_exclusive_with_parallel_jobs_enabled() -> Result<()> { + let Some(workspace) = runtime_workspace() else { + return Ok(()); + }; + let root = workspace_dir(&workspace)?; + let first_release = Utf8PathBuf::from(".release-one"); + let second_release = Utf8PathBuf::from(".release-two"); + write_console_exclusivity_manifest(&root)?; + + let mut command = netsuke_build(&workspace, &["--jobs", "2", "build", "one", "two"])?; + let child = command + .spawn() + .context("spawn Netsuke console exclusivity build")?; + let mut cleanup = ChildCleanup::new( + child, + root.try_clone()?, + vec![first_release.clone(), second_release.clone()], + ); + let stdout = cleanup + .child + .stdout + .take() + .context("Netsuke child should expose stdout")?; + let (output, reader) = spawn_line_reader(stdout); + + let first_start = wait_for_marker(&output, "START:")?; + let first_output = parse_start_output(&first_start)?; + release_edge(&root, &first_output)?; + let second_start = wait_for_marker(&output, "START:")?; + let second_output = parse_start_output(&second_start)?; + ensure!( + first_output != second_output, + "the two console edges should each start once" + ); + release_edge(&root, &second_output)?; + finish_successfully(&mut cleanup)?; + reader + .join() + .map_err(|_| anyhow::anyhow!("stdout forwarding thread panicked"))?; + for target in ["one", "two"] { + ensure!( + root.exists(target), + "console edge {target} should complete after release" + ); + } + ensure!( + !root.exists(".console-running"), + "the shared sentinel should be removed after both recipes finish" + ); + Ok(()) +} + +/// Write the shared-sentinel recipes for the console exclusivity scenario. +/// +/// Keep this fixture private to the integration-test module because its marker +/// and release-file protocol is specific to the two edges under test. +/// +/// # Errors +/// +/// Returns an error when the capability directory cannot write the manifest. +fn write_console_exclusivity_manifest(root: &Dir) -> Result<()> { + root.write( + "Netsukefile", + concat!( + "netsuke_version: \"1.0.0\"\n", + "targets:\n", + " - name: one\n", + " command: >-\n", + " mkdir .console-running 2>/dev/null || { echo COLLISION >&2; exit 1; };\n", + " printf 'START:one\\n';\n", + " while [ ! -f .release-one ]; do sleep 0.02; done;\n", + " rmdir .console-running; touch one; printf 'DONE:one\\n'\n", + " console: true\n", + " - name: two\n", + " command: >-\n", + " mkdir .console-running 2>/dev/null || { echo COLLISION >&2; exit 1; };\n", + " printf 'START:two\\n';\n", + " while [ ! -f .release-two ]; do sleep 0.02; done;\n", + " rmdir .console-running; touch two; printf 'DONE:two\\n'\n", + " console: true\n", + ), + ) + .context("write exclusive console manifest")?; + Ok(()) +} + +/// Release one exclusivity-test recipe by its declared output name. +/// +/// # Errors +/// +/// Returns an error for unexpected output names or failed release-file writes. +fn release_edge(workspace: &Dir, output: &str) -> Result<()> { + ensure!( + matches!(output, "one" | "two"), + "unexpected output marker {output}" + ); + workspace + .write(format!(".release-{output}"), b"release") + .with_context(|| format!("release console edge {output}"))?; + Ok(()) +} + +/// Extract and validate the target name printed by the shared recipe. +/// +/// # Errors +/// +/// Returns an error when the marker is absent or does not name a test output. +fn parse_start_output(line: &str) -> Result { + let output = line + .split_once("START:") + .map(|(_, output)| output.trim()) + .context("console recipe start marker should include its output")?; + ensure!( + matches!(output, "one" | "two"), + "unexpected console output {output}" + ); + Ok(output.to_owned()) +} diff --git a/tests/features/ninja.feature b/tests/features/ninja.feature index 20edb57ff..e72a78d9e 100644 --- a/tests/features/ninja.feature +++ b/tests/features/ninja.feature @@ -17,7 +17,8 @@ Feature: Ninja file generation And the ninja file is generated Then the ninja file contains "build streamed-action:" And the ninja file contains "build inline-console:" - And the ninja file contains " pool = console" + And the Ninja edge "streamed-action" has a console pool binding + And the Ninja edge "inline-console" has a console pool binding Scenario: Dependency-only console edges are rejected Given a dependency-only action graph with console scheduling diff --git a/tests/ir_tests.rs b/tests/ir_tests.rs index e34613c59..163e2ca0d 100644 --- a/tests/ir_tests.rs +++ b/tests/ir_tests.rs @@ -2,8 +2,8 @@ //! //! Directly constructs [`netsuke::ir::BuildGraph`], [`BuildEdge`], and //! [`Action`] values and asserts their default state, field semantics, and -//! canonical edge ownership and output-index lookup. Does not exercise manifest -//! parsing or Ninja generation. +//! canonical edge ownership and output-index lookup. It also checks direct +//! manifest-to-IR validation without invoking Ninja generation. use camino::Utf8PathBuf; use netsuke::ast::Recipe; diff --git a/tests/ninja_snapshot_tests.rs b/tests/ninja_snapshot_tests.rs index 062a8baa5..095534a2f 100644 --- a/tests/ninja_snapshot_tests.rs +++ b/tests/ninja_snapshot_tests.rs @@ -304,7 +304,7 @@ fn manifest_ninja_snapshot( Ok(()) } -/// Assert whether one named edge has an immediately following console binding. +/// Assert whether one named edge has a console binding. /// /// # Errors /// @@ -316,7 +316,11 @@ fn assert_console_edge_binding(ninja: &str, output: &str, expected: bool) -> Res .iter() .position(|line| line.starts_with(&prefix)) .with_context(|| format!("missing Ninja build edge for {output}"))?; - let actual = lines.get(edge_index + 1) == Some(&" pool = console"); + let actual = lines + .iter() + .skip(edge_index + 1) + .take_while(|line| line.starts_with(" ")) + .any(|line| *line == " pool = console"); ensure!( actual == expected, "edge {output} console binding should be {expected}, generated Ninja:\n{ninja}" diff --git a/tests/ui/build_edge_console_compile_pass.rs b/tests/ui/build_edge_console_compile_pass.rs index bb402ecb8..90bdb2972 100644 --- a/tests/ui/build_edge_console_compile_pass.rs +++ b/tests/ui/build_edge_console_compile_pass.rs @@ -1,6 +1,10 @@ //! Compile the public console scheduling flag as an external crate would. -use netsuke::ir::{BuildEdge, DependencyOrder}; +use netsuke::ast::{ + CommandIsolation, DependencyOrder as AstDependencyOrder, Recipe, StringOrList, Target, +}; +use netsuke::ir::{BuildEdge, DependencyOrder as IrDependencyOrder}; +use std::collections::HashMap; /// Construct a public build edge that opts into Ninja's console pool. #[must_use] @@ -9,7 +13,7 @@ pub fn console_edge() -> BuildEdge { action_id: "lint".into(), inputs: Vec::new(), implicit_deps: Vec::new(), - dependency_order: DependencyOrder::Parallel, + dependency_order: IrDependencyOrder::Parallel, explicit_outputs: vec!["lint".into()], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), @@ -18,3 +22,25 @@ pub fn console_edge() -> BuildEdge { console: true, } } + +/// Construct a public target that opts into Ninja's console pool. +#[must_use] +pub fn console_target() -> Target { + Target { + name: "lint".into(), + recipe: Recipe::Command { + command: StringOrList::from("cargo clippy"), + isolation: CommandIsolation::Shared, + }, + sources: StringOrList::Empty, + deps: StringOrList::Empty, + dependency_order: AstDependencyOrder::Parallel, + order_only_deps: StringOrList::Empty, + vars: HashMap::default(), + phony: false, + always: false, + console: true, + conditional: false, + description: None, + } +} From 93face4b306d6625d94c5447360dc6623ff88c0b Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 26 Oct 2026 15:17:46 +0100 Subject: [PATCH 04/27] Address console pool review feedback Use a manifest-specific localized diagnostic and verify its rendered text. Clarify the Ninja scheduling exception in the IR documentation and align the translations with established catalogue terms. --- docs/developers-guide.md | 7 +++++++ docs/netsuke-design.md | 31 ++++++++++++++++-------------- locales/ar/messages.ftl | 1 + locales/cs/messages.ftl | 1 + locales/cy/messages.ftl | 1 + locales/da/messages.ftl | 1 + locales/de/messages.ftl | 1 + locales/el/messages.ftl | 1 + locales/en-GB/messages.ftl | 1 + locales/en-US/messages.ftl | 1 + locales/es-419/messages.ftl | 1 + locales/es-ES/messages.ftl | 1 + locales/fa/messages.ftl | 1 + locales/fi/messages.ftl | 1 + locales/fr/messages.ftl | 1 + locales/gd/messages.ftl | 1 + locales/he/messages.ftl | 1 + locales/hi/messages.ftl | 1 + locales/hu/messages.ftl | 1 + locales/id/messages.ftl | 1 + locales/it/messages.ftl | 1 + locales/ja/messages.ftl | 1 + locales/ko/messages.ftl | 1 + locales/nb/messages.ftl | 1 + locales/nl/messages.ftl | 1 + locales/pl/messages.ftl | 5 +++-- locales/pt-BR/messages.ftl | 5 +++-- locales/pt-PT/messages.ftl | 1 + locales/ro/messages.ftl | 1 + locales/ru/messages.ftl | 1 + locales/sv/messages.ftl | 1 + locales/th/messages.ftl | 1 + locales/tr/messages.ftl | 1 + locales/uk/messages.ftl | 1 + locales/vi/messages.ftl | 1 + locales/zh-Hans/messages.ftl | 1 + locales/zh-Hant/messages.ftl | 1 + src/ir/graph/error.rs | 25 +++++++++++++++++------- src/ir/graph/mod.rs | 1 + src/ir/mod.rs | 1 + src/localization/manifest_keys.rs | 1 + src/manifest/mod.rs | 10 +--------- tests/ast_tests/console.rs | 4 +++- tests/ast_tests/dependency_only.rs | 9 ++++++--- 44 files changed, 94 insertions(+), 38 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 502a3b117..2717b255d 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -6515,6 +6515,13 @@ custom action pools that conflict with `console`; it accepts an existing `Action.pool` value of `console`. Synthetic serial gates and dyndep sidecars never inherit either binding. +AST validation keeps stable diagnostic details separate from localized display +text. `src/ir/graph/error.rs::manifest_recipe_validation_message` maps a +recognized manifest detail to the same user-facing message for parsing and +direct IR lowering. Ninja generator errors use a separate message when an +output edge is available; the build-script localization slice does not depend +on this library-only mapping. + `src/ir/cycle/detector.rs::CycleDetector::visit_known_edge` traverses `inputs`, `implicit_deps`, and `order_only_deps` when detecting cycles. Order-only dependencies do not trigger a rebuild when they change, but they still have to diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 27fe83764..f97f816aa 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -2357,12 +2357,14 @@ distributed build system), only a new generator module (`IR -> NewBackend`) would need to be written, leaving the entire front-end parsing and validation logic untouched. -Importantly, the IR contains **no Ninja-isms**. Netsuke markers such as -`{{ ins }}` and `{{ outs }}` are resolved to plain lists of file paths, and -command strings are expanded before hashing. This deliberate absence of -Ninja-specific syntax makes the IR a stable contract that future -back-ends--distributed builders, remote executors, or otherwise--can consume -without modification. +The IR keeps recipe and command data free of Ninja-specific syntax. Netsuke +markers such as `{{ ins }}` and `{{ outs }}` are resolved to plain lists of +file paths, and command strings are expanded before hashing. The +`BuildEdge.console` scheduling flag described in §5.5 is a deliberate +Ninja-specific exception: it records a Boolean choice without storing a pool +name or placeholder. Recipe and command data therefore provide a stable +contract that future back-ends--distributed builders, remote executors, or +otherwise--can consume without modification. Furthermore, the IR is the ideal stage at which to perform graph-level analysis and optimizations, such as detecting circular dependencies, pruning unused @@ -2875,14 +2877,15 @@ default my_app The live IR structures defined in [src/ir/graph/mod.rs](../src/ir/graph/mod.rs), and re-exported through [src/ir/mod.rs](../src/ir/mod.rs), are minimal containers that mirror Ninja's -conceptual model while remaining backend-agnostic. `BuildGraph` collects -actions in a hash map, canonical edges in an insertion-ordered arena, and -output aliases in a `Utf8PathBuf` to `EdgeId` hash map. Actions hold the parsed -`Recipe` and optional execution metadata. `BuildEdge` connects inputs to -outputs using an action identifier and carries the `phony`, `always`, and -`console` flags verbatim from the manifest. `console` is edge-level scheduling -metadata and does not affect action identity. No Ninja-specific placeholders -are stored in the IR to keep the representation portable. +conceptual model while keeping most scheduling data backend-agnostic. +`BuildGraph` collects actions in a hash map, canonical edges in an +insertion-ordered arena, and output aliases in a `Utf8PathBuf` to `EdgeId` hash +map. Actions hold the parsed `Recipe` and optional execution metadata. +`BuildEdge` connects inputs to outputs using an action identifier and carries +the `phony`, `always`, and `console` flags verbatim from the manifest. The +`console` flag is a deliberate Ninja-specific edge annotation; the IR stores a +Boolean scheduling choice, not a pool name or placeholder. It does not affect +action identity. - Actions are deduplicated using a SHA-256 hash of a canonical JSON serialization of their recipe, inputs, and outputs. Because commands embed diff --git a/locales/ar/messages.ftl b/locales/ar/messages.ftl index 27033c10b..6c4ba4e52 100644 --- a/locales/ar/messages.ftl +++ b/locales/ar/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = خطأ إدخال/إخراج غير معروف. manifest.command_list_empty = يجب ألّا يكون الحقل «command» فارغًا: قدِّم سلسلة أمر أو قائمة غير فارغة. manifest.command_isolation_recipe = يتطلب الحقل «command_isolation» وصفةً تتضمن قائمة أوامر. manifest.command_isolation_scalar = يتطلب «command_isolation: isolated» قائمة أوامر. +manifest.console_dependency_only = لا يمكن للإدخالات التي تقتصر على التبعيات اختيار مجموعة وحدة التحكم. # أخطاء التمثيل الوسيط. ir.rule_not_found = تعذّر العثور على القاعدة «{ $rule }» التي يشير إليها الهدف «{ $target }». diff --git a/locales/cs/messages.ftl b/locales/cs/messages.ftl index e2fffe2d9..fd2c3aa58 100644 --- a/locales/cs/messages.ftl +++ b/locales/cs/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = neznámá vstupně-výstupní chyba. manifest.command_list_empty = Pole „command“ nesmí být prázdné: zadejte řetězec s příkazem nebo neprázdný seznam. manifest.command_isolation_recipe = Pole „command_isolation“ vyžaduje recept se seznamem příkazů. manifest.command_isolation_scalar = „command_isolation: isolated“ vyžaduje seznam příkazů. +manifest.console_dependency_only = Položky obsahující pouze závislosti nemohou vybrat fond konzole. # Chyby mezikódu. ir.rule_not_found = Pravidlo „{ $rule }“, na které odkazuje cíl „{ $target }“, nebylo nalezeno. diff --git a/locales/cy/messages.ftl b/locales/cy/messages.ftl index 6a2f62adc..137ef9ab4 100644 --- a/locales/cy/messages.ftl +++ b/locales/cy/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = gwall mewnbwn/allbwn anhysbys. manifest.command_list_empty = Rhaid i’r maes ‘command’ beidio â bod yn wag: rhowch linyn gorchymyn neu restr nad yw’n wag. manifest.command_isolation_recipe = Mae’r maes ‘command_isolation’ yn gofyn am rysáit rhestr orchmynion. manifest.command_isolation_scalar = Mae ‘command_isolation: isolated’ yn gofyn am restr orchmynion. +manifest.console_dependency_only = Ni all cofnodion sy'n cynnwys dibyniaethau yn unig ddewis pwll y consol. # Gwallau'r cynrychioliad canolradd. ir.rule_not_found = Ni chafwyd hyd i'r rheol ‘{ $rule }’ y cyfeirir ati gan y targed ‘{ $target }’. diff --git a/locales/da/messages.ftl b/locales/da/messages.ftl index 42eef41e7..7e4cb239d 100644 --- a/locales/da/messages.ftl +++ b/locales/da/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = ukendt I/O-fejl. manifest.command_list_empty = Feltet "command" må ikke være tomt: angiv en kommandostreng eller en ikke-tom liste. manifest.command_isolation_recipe = Feltet ‘command_isolation’ kræver en opskrift med en kommandoliste. manifest.command_isolation_scalar = ‘command_isolation: isolated’ kræver en kommandoliste. +manifest.console_dependency_only = Poster, der kun angiver afhængigheder, kan ikke vælge konsolpuljen. # Fejl i den interne repræsentation. ir.rule_not_found = Reglen "{ $rule }", som målet "{ $target }" henviser til, blev ikke fundet. diff --git a/locales/de/messages.ftl b/locales/de/messages.ftl index 3059b4084..57513b196 100644 --- a/locales/de/messages.ftl +++ b/locales/de/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = unbekannter E/A-Fehler. manifest.command_list_empty = Das Feld „command“ darf nicht leer sein: Geben Sie eine Befehlszeichenkette oder eine nicht leere Liste an. manifest.command_isolation_recipe = Das Feld „command_isolation“ erfordert ein Rezept mit einer Befehlsliste. manifest.command_isolation_scalar = „command_isolation: isolated“ erfordert eine Befehlsliste. +manifest.console_dependency_only = Einträge, die nur Abhängigkeiten angeben, können den Konsolen-Pool nicht auswählen. # Fehler der Zwischendarstellung. ir.rule_not_found = Die vom Ziel „{ $target }“ referenzierte Regel „{ $rule }“ wurde nicht gefunden. diff --git a/locales/el/messages.ftl b/locales/el/messages.ftl index 3417aaa67..20a7df1e5 100644 --- a/locales/el/messages.ftl +++ b/locales/el/messages.ftl @@ -187,6 +187,7 @@ manifest.glob.unknown_io_error = άγνωστο σφάλμα εισόδου/εξ manifest.command_list_empty = Το πεδίο «command» δεν πρέπει να είναι κενό: δώστε μια συμβολοσειρά εντολής ή μια μη κενή λίστα. manifest.command_isolation_recipe = Το πεδίο ‘command_isolation’ απαιτεί συνταγή με λίστα εντολών. manifest.command_isolation_scalar = Το ‘command_isolation: isolated’ απαιτεί λίστα εντολών. +manifest.console_dependency_only = Οι εγγραφές που δηλώνουν μόνο εξαρτήσεις δεν μπορούν να επιλέξουν το pool της κονσόλας. # Σφάλματα της ενδιάμεσης αναπαράστασης. ir.rule_not_found = Ο κανόνας «{ $rule }» στον οποίο παραπέμπει ο στόχος «{ $target }» δεν βρέθηκε. diff --git a/locales/en-GB/messages.ftl b/locales/en-GB/messages.ftl index 17ca779e4..560e97a3d 100644 --- a/locales/en-GB/messages.ftl +++ b/locales/en-GB/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = unknown I/O error. manifest.command_list_empty = The 'command' field must not be empty: provide a command string or a non-empty list. manifest.command_isolation_recipe = The 'command_isolation' field requires a command-list recipe. manifest.command_isolation_scalar = 'command_isolation: isolated' requires a command list. +manifest.console_dependency_only = Dependency-only entries cannot select the console pool. # IR errors. ir.rule_not_found = Rule '{ $rule }' referenced by target '{ $target }' was not found. diff --git a/locales/en-US/messages.ftl b/locales/en-US/messages.ftl index 3f2e0cb48..650f0788b 100644 --- a/locales/en-US/messages.ftl +++ b/locales/en-US/messages.ftl @@ -189,6 +189,7 @@ manifest.glob.unknown_io_error = unknown IO error. manifest.command_list_empty = The 'command' field must not be empty: provide a command string or a non-empty list. manifest.command_isolation_recipe = The 'command_isolation' field requires a command-list recipe. manifest.command_isolation_scalar = 'command_isolation: isolated' requires a command list. +manifest.console_dependency_only = Dependency-only entries cannot select the console pool. # IR errors. ir.rule_not_found = Rule '{ $rule }' referenced by target '{ $target }' was not found. diff --git a/locales/es-419/messages.ftl b/locales/es-419/messages.ftl index 3f341cd66..0c221c06a 100644 --- a/locales/es-419/messages.ftl +++ b/locales/es-419/messages.ftl @@ -187,6 +187,7 @@ manifest.glob.unknown_io_error = error de E/S desconocido. manifest.command_list_empty = El campo 'command' no debe estar vacío: proporcione una cadena de comando o una lista no vacía. manifest.command_isolation_recipe = El campo 'command_isolation' requiere una receta con una lista de comandos. manifest.command_isolation_scalar = 'command_isolation: isolated' requiere una lista de comandos. +manifest.console_dependency_only = Las entradas que solo declaran dependencias no pueden seleccionar el grupo de la consola. # Errores de la representación intermedia. ir.rule_not_found = No se encontró la regla '{ $rule }' referenciada por el objetivo '{ $target }'. diff --git a/locales/es-ES/messages.ftl b/locales/es-ES/messages.ftl index 1f0e066c7..5b0b8b17b 100644 --- a/locales/es-ES/messages.ftl +++ b/locales/es-ES/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = error de E/S desconocido. manifest.command_list_empty = El campo 'command' no debe estar vacío: proporcione una cadena de comando o una lista no vacía. manifest.command_isolation_recipe = El campo 'command_isolation' requiere una receta con una lista de comandos. manifest.command_isolation_scalar = 'command_isolation: isolated' requiere una lista de comandos. +manifest.console_dependency_only = Las entradas que solo declaran dependencias no pueden seleccionar el grupo de la consola. # Errores de IR. ir.rule_not_found = No se encontró la regla '{ $rule }' referenciada por el objetivo '{ $target }'. diff --git a/locales/fa/messages.ftl b/locales/fa/messages.ftl index a6a413aa9..f70835a7c 100644 --- a/locales/fa/messages.ftl +++ b/locales/fa/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = خطای ورودی/خروجی ناشناخته manifest.command_list_empty = فیلد «command» نباید خالی باشد: یک رشتهٔ فرمان یا فهرستی ناتهی ارائه دهید. manifest.command_isolation_recipe = فیلد «command_isolation» به دستوری با فهرست فرمان‌ها نیاز دارد. manifest.command_isolation_scalar = ‏«command_isolation: isolated» به فهرست فرمان‌ها نیاز دارد. +manifest.console_dependency_only = ورودی‌هایی که فقط وابستگی‌ها را اعلام می‌کنند نمی‌توانند مخزن کنسول را انتخاب کنند. # خطاهای بازنمایی میانی. ir.rule_not_found = قاعدهٔ «{ $rule }» که هدف «{ $target }» به آن ارجاع می‌دهد یافت نشد. diff --git a/locales/fi/messages.ftl b/locales/fi/messages.ftl index 40edcbce5..e304c865f 100644 --- a/locales/fi/messages.ftl +++ b/locales/fi/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = tuntematon siirräntävirhe. manifest.command_list_empty = Kenttä ”command” ei saa olla tyhjä: anna komentomerkkijono tai ei-tyhjä luettelo. manifest.command_isolation_recipe = Kenttä ”command_isolation” edellyttää komentoluetteloreseptiä. manifest.command_isolation_scalar = ”command_isolation: isolated” edellyttää komentoluetteloa. +manifest.console_dependency_only = Vain riippuvuuksia ilmoittavat tietueet eivät voi valita konsolipoolia. # Välimuotoesityksen virheet. ir.rule_not_found = Sääntöä ”{ $rule }”, johon kohde ”{ $target }” viittaa, ei löytynyt. diff --git a/locales/fr/messages.ftl b/locales/fr/messages.ftl index 3db862029..29479c894 100644 --- a/locales/fr/messages.ftl +++ b/locales/fr/messages.ftl @@ -187,6 +187,7 @@ manifest.glob.unknown_io_error = erreur d'E/S inconnue. manifest.command_list_empty = Le champ « command » ne doit pas être vide : indiquez une chaîne de commande ou une liste non vide. manifest.command_isolation_recipe = Le champ « command_isolation » exige une recette avec une liste de commandes. manifest.command_isolation_scalar = « command_isolation: isolated » exige une liste de commandes. +manifest.console_dependency_only = Les entrées qui ne déclarent que des dépendances ne peuvent pas sélectionner le pool console. # Erreurs de la représentation intermédiaire. ir.rule_not_found = La règle « { $rule } » référencée par la cible « { $target } » est introuvable. diff --git a/locales/gd/messages.ftl b/locales/gd/messages.ftl index 64aa37306..7128552d5 100644 --- a/locales/gd/messages.ftl +++ b/locales/gd/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = mearachd ion-chuir/às-chuir neo-aithnichte. manifest.command_list_empty = Chan fhaod an raon “command” a bhith falamh: thoir seachad sreang àithne no liosta nach eil falamh. manifest.command_isolation_recipe = Feumaidh an raon ‘command_isolation’ reasabaidh le liosta àitheantan. manifest.command_isolation_scalar = Feumaidh ‘command_isolation: isolated’ liosta àitheantan. +manifest.console_dependency_only = Chan urrainn do dh’inntrigidhean a tha ag ainmeachadh eisimeileachdan a-mhàin amar a’ chonsail a thaghadh. # Mearachdan an riochdachaidh mheadhanaich. ir.rule_not_found = Cha deach an riaghailt “{ $rule }” air a bheil an targaid “{ $target }” a' toirt iomradh a lorg. diff --git a/locales/he/messages.ftl b/locales/he/messages.ftl index a4126187d..da9a3a682 100644 --- a/locales/he/messages.ftl +++ b/locales/he/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = שגיאת קלט/פלט לא ידועה. manifest.command_list_empty = השדה „command” אינו יכול להיות ריק: יש לספק מחרוזת פקודה או רשימה שאינה ריקה. manifest.command_isolation_recipe = השדה ‘command_isolation’ מחייב מתכון עם רשימת פקודות. manifest.command_isolation_scalar = ‏‘command_isolation: isolated’ מחייב רשימת פקודות. +manifest.console_dependency_only = רשומות שמציינות תלויות בלבד אינן יכולות לבחור במאגר המסוף. # שגיאות הייצוג הביניימי. ir.rule_not_found = הכלל „{ $rule }” שאליו מפנה היעד „{ $target }” לא נמצא. diff --git a/locales/hi/messages.ftl b/locales/hi/messages.ftl index fe3f8376e..c610c9909 100644 --- a/locales/hi/messages.ftl +++ b/locales/hi/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = अज्ञात इनपुट/आउटप manifest.command_list_empty = “command” फ़ील्ड रिक्त नहीं होना चाहिए: कोई कमांड स्ट्रिंग या ग़ैर-रिक्त सूची दें। manifest.command_isolation_recipe = 'command_isolation' फ़ील्ड के लिए कमांड-सूची वाली रेसिपी आवश्यक है। manifest.command_isolation_scalar = 'command_isolation: isolated' के लिए कमांड सूची आवश्यक है। +manifest.console_dependency_only = केवल निर्भरताएँ बताने वाली प्रविष्टियाँ कंसोल पूल का चयन नहीं कर सकतीं। # मध्यवर्ती निरूपण की त्रुटियाँ। ir.rule_not_found = लक्ष्य “{ $target }” जिस नियम “{ $rule }” का संदर्भ देता है वह नहीं मिला। diff --git a/locales/hu/messages.ftl b/locales/hu/messages.ftl index c62f6a4fd..75e691501 100644 --- a/locales/hu/messages.ftl +++ b/locales/hu/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = ismeretlen be- és kiviteli hiba. manifest.command_list_empty = A „command” mező nem lehet üres: adjon meg egy parancs-karakterláncot vagy egy nem üres listát. manifest.command_isolation_recipe = Az „command_isolation” mezőhöz parancslistás recept szükséges. manifest.command_isolation_scalar = Az „command_isolation: isolated” értékhez parancslista szükséges. +manifest.console_dependency_only = A csak függőségeket megadó bejegyzések nem választhatják ki a konzolkészletet. # A köztes ábrázolás hibái. ir.rule_not_found = A(z) „{ $target }” cél által hivatkozott „{ $rule }” szabály nem található. diff --git a/locales/id/messages.ftl b/locales/id/messages.ftl index bd58065d0..ffd66a13c 100644 --- a/locales/id/messages.ftl +++ b/locales/id/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = galat masukan/keluaran yang tidak dikenal. manifest.command_list_empty = Bidang "command" tidak boleh kosong: berikan string perintah atau daftar yang tidak kosong. manifest.command_isolation_recipe = Bidang ‘command_isolation’ memerlukan resep dengan daftar perintah. manifest.command_isolation_scalar = ‘command_isolation: isolated’ memerlukan daftar perintah. +manifest.console_dependency_only = Entri yang hanya menyatakan dependensi tidak dapat memilih pool konsol. # Galat representasi antara. ir.rule_not_found = Aturan "{ $rule }" yang dirujuk target "{ $target }" tidak ditemukan. diff --git a/locales/it/messages.ftl b/locales/it/messages.ftl index 9b39d61c0..4c7e382ad 100644 --- a/locales/it/messages.ftl +++ b/locales/it/messages.ftl @@ -187,6 +187,7 @@ manifest.glob.unknown_io_error = errore di I/O sconosciuto. manifest.command_list_empty = Il campo «command» non deve essere vuoto: fornire una stringa di comando o un elenco non vuoto. manifest.command_isolation_recipe = Il campo «command_isolation» richiede una ricetta con un elenco di comandi. manifest.command_isolation_scalar = «command_isolation: isolated» richiede un elenco di comandi. +manifest.console_dependency_only = Le voci che dichiarano solo dipendenze non possono selezionare il pool console. # Errori della rappresentazione intermedia. ir.rule_not_found = La regola «{ $rule }» referenziata dal target «{ $target }» non è stata trovata. diff --git a/locales/ja/messages.ftl b/locales/ja/messages.ftl index cd67e2b11..aa653a48d 100644 --- a/locales/ja/messages.ftl +++ b/locales/ja/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = 不明な入出力エラー。 manifest.command_list_empty = 「command」フィールドは空にできません: コマンド文字列または空でないリストを指定してください。 manifest.command_isolation_recipe = 「command_isolation」フィールドにはコマンドリストのレシピが必要です。 manifest.command_isolation_scalar = 「command_isolation: isolated」にはコマンドリストが必要です。 +manifest.console_dependency_only = 依存関係のみを宣言する項目は、コンソールプールを選択できません。 # 中間表現のエラー。 ir.rule_not_found = ターゲット「{ $target }」が参照する規則「{ $rule }」が見つかりません。 diff --git a/locales/ko/messages.ftl b/locales/ko/messages.ftl index 342d2b280..a58379f86 100644 --- a/locales/ko/messages.ftl +++ b/locales/ko/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = 알 수 없는 입출력 오류. manifest.command_list_empty = 'command' 필드는 비어 있을 수 없습니다: 명령 문자열 또는 비어 있지 않은 목록을 지정하십시오. manifest.command_isolation_recipe = ‘command_isolation’ 필드에는 명령 목록 레시피가 필요합니다. manifest.command_isolation_scalar = ‘command_isolation: isolated’에는 명령 목록이 필요합니다. +manifest.console_dependency_only = 종속성만 지정하는 항목은 콘솔 풀을 선택할 수 없습니다. # 중간 표현 오류. ir.rule_not_found = 대상 '{ $target }'이(가) 참조하는 규칙 '{ $rule }'을(를) 찾을 수 없습니다. diff --git a/locales/nb/messages.ftl b/locales/nb/messages.ftl index 3dd83df98..61dd00b34 100644 --- a/locales/nb/messages.ftl +++ b/locales/nb/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = ukjent I/U-feil. manifest.command_list_empty = Feltet «command» kan ikke være tomt: oppgi en kommandostreng eller en ikke-tom liste. manifest.command_isolation_recipe = Feltet ‘command_isolation’ krever en oppskrift med en kommandoliste. manifest.command_isolation_scalar = ‘command_isolation: isolated’ krever en kommandoliste. +manifest.console_dependency_only = Oppføringer som bare angir avhengigheter, kan ikke velge konsollpuljen. # Feil i den interne representasjonen. ir.rule_not_found = Regelen «{ $rule }» som målet «{ $target }» viser til, ble ikke funnet. diff --git a/locales/nl/messages.ftl b/locales/nl/messages.ftl index 6a988dd4c..395d35d0c 100644 --- a/locales/nl/messages.ftl +++ b/locales/nl/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = onbekende I/O-fout. manifest.command_list_empty = Het veld ‘command’ mag niet leeg zijn: geef een opdrachtreeks of een niet-lege lijst op. manifest.command_isolation_recipe = Het veld ‘command_isolation’ vereist een recept met een opdrachtenlijst. manifest.command_isolation_scalar = ‘command_isolation: isolated’ vereist een opdrachtenlijst. +manifest.console_dependency_only = Items die alleen afhankelijkheden aangeven, kunnen de consolepool niet selecteren. # Fouten in de tussenrepresentatie. ir.rule_not_found = De regel ‘{ $rule }’ waarnaar doel ‘{ $target }’ verwijst, is niet gevonden. diff --git a/locales/pl/messages.ftl b/locales/pl/messages.ftl index be78eabb0..1b59c3b13 100644 --- a/locales/pl/messages.ftl +++ b/locales/pl/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = nieznany błąd wejścia/wyjścia. manifest.command_list_empty = Pole „command” nie może być puste: podaj łańcuch polecenia lub niepustą listę. manifest.command_isolation_recipe = Pole „command_isolation” wymaga receptury z listą poleceń. manifest.command_isolation_scalar = „command_isolation: isolated” wymaga listy poleceń. +manifest.console_dependency_only = Wpisy, które deklarują wyłącznie zależności, nie mogą wybrać puli konsoli. # Błędy reprezentacji pośredniej. ir.rule_not_found = Nie znaleziono reguły „{ $rule }”, do której odwołuje się cel „{ $target }”. @@ -198,8 +199,8 @@ ir.invalid_command = Nieprawidłowa interpolacja w poleceniu: { $snippet }. # Błędy generowania plików Ninja. ninja_gen.missing_action = Brakuje akcji „{ $id }”, do której odwołuje się krawędź budowania. -ninja_gen.console_dependency_only = Krawędź kompilacji „{ $output }” nie może wybrać puli konsoli, ponieważ jej akcja zawiera tylko zależności. -ninja_gen.console_pool_conflict = Krawędź kompilacji „{ $output }” wybiera pulę konsoli, ale jej akcja żąda niestandardowej puli „{ $pool }”. +ninja_gen.console_dependency_only = Krawędź budowania „{ $output }” nie może wybrać puli konsoli, ponieważ jej akcja zawiera tylko zależności. +ninja_gen.console_pool_conflict = Krawędź budowania „{ $output }” wybiera pulę konsoli, ale jej akcja żąda niestandardowej puli „{ $pool }”. ninja_gen.format = Nie udało się sformatować wyjścia manifestu Ninja. ninja_gen.dyndep_files_required = Ta operacja wymaga wygenerowanego pakietu Ninja; użyj `netsuke build`, `netsuke clean` lub `netsuke generate`, aby utworzyć pliki dyndep. ninja_gen.reserved_output_path = Ścieżka '{ $path }' jest zarezerwowana dla stanu szeregowych zależności Netsuke. diff --git a/locales/pt-BR/messages.ftl b/locales/pt-BR/messages.ftl index 02456619e..132996cb1 100644 --- a/locales/pt-BR/messages.ftl +++ b/locales/pt-BR/messages.ftl @@ -187,6 +187,7 @@ manifest.glob.unknown_io_error = erro de E/S desconhecido. manifest.command_list_empty = O campo "command" não pode estar vazio: forneça uma cadeia de comando ou uma lista não vazia. manifest.command_isolation_recipe = O campo ‘command_isolation’ exige uma receita com uma lista de comandos. manifest.command_isolation_scalar = ‘command_isolation: isolated’ exige uma lista de comandos. +manifest.console_dependency_only = Entradas que declaram apenas dependências não podem selecionar o pool do console. # Erros da representação intermediária. ir.rule_not_found = A regra "{ $rule }" referenciada pelo alvo "{ $target }" não foi encontrada. @@ -199,8 +200,8 @@ ir.invalid_command = Interpolação de comando inválida: { $snippet }. # Erros de geração do Ninja. ninja_gen.missing_action = Falta a ação "{ $id }" referenciada por uma aresta de build. -ninja_gen.console_dependency_only = A aresta de compilação "{ $output }" não pode selecionar o pool do console porque sua ação declara apenas dependências. -ninja_gen.console_pool_conflict = A aresta de compilação "{ $output }" seleciona o pool do console, mas sua ação solicita o pool personalizado "{ $pool }". +ninja_gen.console_dependency_only = A aresta de build "{ $output }" não pode selecionar o pool do console porque sua ação declara apenas dependências. +ninja_gen.console_pool_conflict = A aresta de build "{ $output }" seleciona o pool do console, mas sua ação solicita o pool personalizado "{ $pool }". ninja_gen.format = Não foi possível formatar a saída do manifesto do Ninja. ninja_gen.dyndep_files_required = Esta operação requer um pacote Ninja gerado; use `netsuke build`, `netsuke clean` ou `netsuke generate` para materializar os arquivos dyndep. ninja_gen.reserved_output_path = O caminho '{ $path }' está reservado para o estado de dependências seriais do Netsuke. diff --git a/locales/pt-PT/messages.ftl b/locales/pt-PT/messages.ftl index 448f12158..dfd4db298 100644 --- a/locales/pt-PT/messages.ftl +++ b/locales/pt-PT/messages.ftl @@ -187,6 +187,7 @@ manifest.glob.unknown_io_error = erro de E/S desconhecido. manifest.command_list_empty = O campo «command» não pode estar vazio: forneça uma cadeia de comando ou uma lista não vazia. manifest.command_isolation_recipe = O campo «command_isolation» exige uma receita com uma lista de comandos. manifest.command_isolation_scalar = «command_isolation: isolated» exige uma lista de comandos. +manifest.console_dependency_only = Entradas que declaram apenas dependências não podem selecionar o conjunto da consola. # Erros da representação intermédia. ir.rule_not_found = A regra «{ $rule }» referenciada pelo alvo «{ $target }» não foi encontrada. diff --git a/locales/ro/messages.ftl b/locales/ro/messages.ftl index 3a587fc32..95de89ed1 100644 --- a/locales/ro/messages.ftl +++ b/locales/ro/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = eroare de intrare/ieșire necunoscută. manifest.command_list_empty = Câmpul „command” nu trebuie să fie gol: furnizați un șir de comandă sau o listă nevidă. manifest.command_isolation_recipe = Câmpul „command_isolation” necesită o rețetă cu o listă de comenzi. manifest.command_isolation_scalar = „command_isolation: isolated” necesită o listă de comenzi. +manifest.console_dependency_only = Intrările care declară doar dependențe nu pot selecta grupul console. # Erori ale reprezentării intermediare. ir.rule_not_found = Regula „{ $rule }” la care face referire ținta „{ $target }” nu a fost găsită. diff --git a/locales/ru/messages.ftl b/locales/ru/messages.ftl index 9a8fad417..f7e67f180 100644 --- a/locales/ru/messages.ftl +++ b/locales/ru/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = неизвестная ошибка ввода- manifest.command_list_empty = Поле «command» не должно быть пустым: укажите строку команды или непустой список. manifest.command_isolation_recipe = Поле «command_isolation» требует рецепт со списком команд. manifest.command_isolation_scalar = «command_isolation: isolated» требует список команд. +manifest.console_dependency_only = Записи, объявляющие только зависимости, не могут выбрать пул консоли. # Ошибки промежуточного представления. ir.rule_not_found = Правило «{ $rule }», на которое ссылается цель «{ $target }», не найдено. diff --git a/locales/sv/messages.ftl b/locales/sv/messages.ftl index a187be934..632687c54 100644 --- a/locales/sv/messages.ftl +++ b/locales/sv/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = okänt I/O-fel. manifest.command_list_empty = Fältet ”command” får inte vara tomt: ange en kommandosträng eller en icke-tom lista. manifest.command_isolation_recipe = Fältet ”command_isolation” kräver ett recept med en kommandolista. manifest.command_isolation_scalar = ”command_isolation: isolated” kräver en kommandolista. +manifest.console_dependency_only = Poster som bara anger beroenden kan inte välja konsolpoolen. # Fel i den interna representationen. ir.rule_not_found = Regeln ”{ $rule }” som målet ”{ $target }” hänvisar till hittades inte. diff --git a/locales/th/messages.ftl b/locales/th/messages.ftl index 31c5d2505..8292c6fb8 100644 --- a/locales/th/messages.ftl +++ b/locales/th/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = ข้อผิดพลาดรับส่ manifest.command_list_empty = ฟิลด์ “command” ต้องไม่ว่าง: ระบุสตริงคำสั่งหรือรายการที่ไม่ว่าง manifest.command_isolation_recipe = ฟิลด์ “command_isolation” ต้องใช้สูตรที่มีรายการคำสั่ง manifest.command_isolation_scalar = “command_isolation: isolated” ต้องใช้รายการคำสั่ง +manifest.console_dependency_only = รายการที่ระบุเฉพาะการพึ่งพาไม่สามารถเลือกพูลคอนโซลได้ # ข้อผิดพลาดของรูปแทนระดับกลาง ir.rule_not_found = ไม่พบกฎ “{ $rule }” ที่เป้าหมาย “{ $target }” อ้างถึง diff --git a/locales/tr/messages.ftl b/locales/tr/messages.ftl index 694576617..a7e0fd1a5 100644 --- a/locales/tr/messages.ftl +++ b/locales/tr/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = bilinmeyen G/Ç hatası. manifest.command_list_empty = "command" alanı boş olmamalıdır: bir komut dizesi veya boş olmayan bir liste verin. manifest.command_isolation_recipe = "command_isolation" alanı, komut listesi içeren bir tarif gerektirir. manifest.command_isolation_scalar = "command_isolation: isolated" bir komut listesi gerektirir. +manifest.console_dependency_only = Yalnızca bağımlılık bildiren girdiler konsol havuzunu seçemez. # Ara gösterim hataları. ir.rule_not_found = "{ $target }" hedefinin başvurduğu "{ $rule }" kuralı bulunamadı. diff --git a/locales/uk/messages.ftl b/locales/uk/messages.ftl index f763a2097..1b72f0f98 100644 --- a/locales/uk/messages.ftl +++ b/locales/uk/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = невідома помилка вводу-ви manifest.command_list_empty = Поле «command» не має бути порожнім: укажіть рядок команди або непорожній список. manifest.command_isolation_recipe = Поле «command_isolation» потребує рецепта зі списком команд. manifest.command_isolation_scalar = «command_isolation: isolated» потребує списку команд. +manifest.console_dependency_only = Записи, що містять лише залежності, не можуть вибрати пул консолі. # Помилки проміжного подання. ir.rule_not_found = Правило «{ $rule }», на яке посилається ціль «{ $target }», не знайдено. diff --git a/locales/vi/messages.ftl b/locales/vi/messages.ftl index 5e2d14a17..6b0333aa9 100644 --- a/locales/vi/messages.ftl +++ b/locales/vi/messages.ftl @@ -186,6 +186,7 @@ manifest.glob.unknown_io_error = lỗi vào/ra không xác định. manifest.command_list_empty = Trường “command” không được để trống: hãy cung cấp một chuỗi lệnh hoặc một danh sách không rỗng. manifest.command_isolation_recipe = Trường “command_isolation” yêu cầu công thức có danh sách lệnh. manifest.command_isolation_scalar = “command_isolation: isolated” yêu cầu danh sách lệnh. +manifest.console_dependency_only = Các mục chỉ khai báo phần phụ thuộc không thể chọn nhóm console. # Lỗi của biểu diễn trung gian. ir.rule_not_found = Không tìm thấy quy tắc “{ $rule }” mà đích “{ $target }” tham chiếu. diff --git a/locales/zh-Hans/messages.ftl b/locales/zh-Hans/messages.ftl index 6b4237618..eb52c86d0 100644 --- a/locales/zh-Hans/messages.ftl +++ b/locales/zh-Hans/messages.ftl @@ -185,6 +185,7 @@ manifest.glob.unknown_io_error = 未知的输入输出错误。 manifest.command_list_empty = “command”字段不能为空:请提供命令字符串或非空列表。 manifest.command_isolation_recipe = “command_isolation”字段需要包含命令列表的配方。 manifest.command_isolation_scalar = “command_isolation: isolated”需要命令列表。 +manifest.console_dependency_only = 仅声明依赖项的条目无法选择控制台池。 # 中间表示的错误。 ir.rule_not_found = 找不到目标“{ $target }”引用的规则“{ $rule }”。 diff --git a/locales/zh-Hant/messages.ftl b/locales/zh-Hant/messages.ftl index b80f963dd..c1df50cb7 100644 --- a/locales/zh-Hant/messages.ftl +++ b/locales/zh-Hant/messages.ftl @@ -185,6 +185,7 @@ manifest.glob.unknown_io_error = 未知的輸入輸出錯誤。 manifest.command_list_empty = 「command」欄位不得為空:請提供命令字串或非空清單。 manifest.command_isolation_recipe = 「command_isolation」欄位需要包含命令清單的配方。 manifest.command_isolation_scalar = 「command_isolation: isolated」需要命令清單。 +manifest.console_dependency_only = 僅宣告相依項目的項目無法選取主控台集區。 # 中介表示法的錯誤。 ir.rule_not_found = 找不到目標「{ $target }」所參照的規則「{ $rule }」。 diff --git a/src/ir/graph/error.rs b/src/ir/graph/error.rs index e2b9c3e8d..cf1d2e4cc 100644 --- a/src/ir/graph/error.rs +++ b/src/ir/graph/error.rs @@ -7,15 +7,26 @@ use crate::localization::{self, LocalizedMessage, keys}; use camino::Utf8PathBuf; use thiserror::Error; +/// Return the localized message for a recognized manifest validation detail. +/// +/// Manifest parsing and direct IR lowering share this message, while Ninja +/// generation uses output-specific diagnostics when a build edge is available. +/// +/// # Examples +/// +/// ```text +/// "console cannot be enabled for dependency-only entries" +/// -> localized "Dependency-only entries cannot select the console pool." +/// ``` +pub(crate) fn manifest_recipe_validation_message(detail: &'static str) -> Option { + (detail == crate::ast::CONSOLE_DEPENDENCY_ONLY_ERROR) + .then(|| localization::message(keys::MANIFEST_CONSOLE_DEPENDENCY_ONLY)) +} + /// Render a manifest validation diagnostic in the active locale when possible. fn invalid_manifest_message(message: &'static str) -> String { - if message == crate::ast::CONSOLE_DEPENDENCY_ONLY_ERROR { - localization::message(keys::NINJA_GEN_CONSOLE_DEPENDENCY_ONLY) - .with_arg("output", "dependency-only entry") - .to_string() - } else { - message.to_owned() - } + manifest_recipe_validation_message(message) + .map_or_else(|| message.to_owned(), |localized| localized.to_string()) } /// Errors produced during IR generation. diff --git a/src/ir/graph/mod.rs b/src/ir/graph/mod.rs index a60593a50..3f4e0e5a6 100644 --- a/src/ir/graph/mod.rs +++ b/src/ir/graph/mod.rs @@ -309,3 +309,4 @@ mod error; mod property_tests; pub use error::IrGenError; +pub(crate) use error::manifest_recipe_validation_message; diff --git a/src/ir/mod.rs b/src/ir/mod.rs index 24a86a37a..c455a0ffc 100644 --- a/src/ir/mod.rs +++ b/src/ir/mod.rs @@ -35,4 +35,5 @@ mod from_manifest; mod graph; pub use cmd_interpolate::{INS_TOKEN, OUTS_TOKEN}; +pub(crate) use graph::manifest_recipe_validation_message; pub use graph::{Action, BuildEdge, BuildGraph, DependencyOrder, EdgeArena, EdgeId, IrGenError}; diff --git a/src/localization/manifest_keys.rs b/src/localization/manifest_keys.rs index 1c5f509fc..0eaecc385 100644 --- a/src/localization/manifest_keys.rs +++ b/src/localization/manifest_keys.rs @@ -59,4 +59,5 @@ define_keys! { MANIFEST_COMMAND_LIST_EMPTY => "manifest.command_list_empty", MANIFEST_COMMAND_ISOLATION_RECIPE => "manifest.command_isolation_recipe", MANIFEST_COMMAND_ISOLATION_SCALAR => "manifest.command_isolation_scalar", + MANIFEST_CONSOLE_DEPENDENCY_ONLY => "manifest.console_dependency_only", } diff --git a/src/manifest/mod.rs b/src/manifest/mod.rs index 9e5c5d35e..e024ea003 100644 --- a/src/manifest/mod.rs +++ b/src/manifest/mod.rs @@ -153,14 +153,6 @@ struct ExpansionRequest<'name, 'a, 'stage> { on_stage: &'a mut Option<&'stage mut dyn FnMut(ManifestLoadStage)>, } -/// Localize recipe validation errors that have a matching user-facing message. -fn recipe_validation_message(detail: &'static str) -> Option { - (detail == crate::ast::CONSOLE_DEPENDENCY_ONLY_ERROR).then(|| { - localization::message(keys::NINJA_GEN_CONSOLE_DEPENDENCY_ONLY) - .with_arg("output", "dependency-only entry") - }) -} - /// Parse, render, and validate a manifest with injected loading boundaries. /// /// Render Jinja values, anchor relative `glob()` patterns at `manifest_root`, @@ -271,7 +263,7 @@ fn expand_and_render(request: ExpansionRequest<'_, '_, '_>) -> Result Result<()> { "manifest validation should retain the parse diagnostic path: {error:#}" ); let rendered = format!("{error:#}"); + let expected = localization::message(keys::MANIFEST_CONSOLE_DEPENDENCY_ONLY).to_string(); ensure!( - rendered.contains("arête de compilation"), + rendered.contains(&expected), "dependency-only console diagnostic should use the French catalogue: {rendered}" ); Ok(()) diff --git a/tests/ast_tests/dependency_only.rs b/tests/ast_tests/dependency_only.rs index 6105238c1..81f057546 100644 --- a/tests/ast_tests/dependency_only.rs +++ b/tests/ast_tests/dependency_only.rs @@ -235,8 +235,9 @@ fn dependency_only_console_entries_are_rejected(#[case] sections: &str) -> Resul let error = parse_manifest(&yaml).expect_err("dependency-only entries cannot use the console pool"); let chain = display_error_chain(error.as_ref()); + let expected = localization::message(keys::MANIFEST_CONSOLE_DEPENDENCY_ONLY).to_string(); ensure!( - chain.contains("console pool") && chain.contains("dependency-only"), + chain.contains(&expected), "unexpected console dependency-only diagnostic: {chain}" ); Ok(()) @@ -265,9 +266,11 @@ fn direct_ir_lowering_preserves_console_dependency_only_diagnostic() -> Result<( ), "direct AST lowering should preserve the console diagnostic code: {error}" ); + let rendered = error.to_string(); + let expected = localization::message(keys::MANIFEST_CONSOLE_DEPENDENCY_ONLY).to_string(); ensure!( - error.to_string().contains("arête de compilation"), - "direct AST lowering should localize the console diagnostic: {error}" + rendered == expected, + "direct AST lowering should render exactly the localized manifest diagnostic: {rendered}" ); Ok(()) } From 664f18b9c8661f3f83b65e174ace92c9ecad5ac3 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 26 Oct 2026 20:50:27 +0100 Subject: [PATCH 05/27] Add console-pool selection for build edges (#946) Carry a default-false opt-in from actions and targets through the IR to edge-level Ninja bindings, with aggregate and custom-pool validation. Cover generation, output streaming, and console exclusivity. Document the serialization trade-off and output limits, and record bounded scheduling telemetry. --- docs/developers-guide.md | 42 +++ docs/v0-1-0-migration-guide.md | 115 +++++++-- locales/nl/messages.ftl | 4 +- src/ir/graph/error.rs | 2 +- src/ir/mod.rs | 6 +- src/ninja_gen/dyndep/bundle.rs | 8 + src/ninja_gen/dyndep/mod.rs | 4 +- src/ninja_gen/dyndep/tests/console.rs | 4 + src/ninja_gen/tests/console.rs | 25 +- src/observability/recorder/console.rs | 66 +++++ src/observability/recorder/mod.rs | 6 +- .../recorder/tests/console_tests.rs | 89 +++++++ src/observability/recorder/tests/mod.rs | 2 + src/runner/console_telemetry/mod.rs | 116 +++++++++ src/runner/console_telemetry/tests.rs | 117 +++++++++ src/runner/dyndep/generation_telemetry.rs | 239 +++++++++++++----- src/runner/mod.rs | 40 +-- test_support/src/lib.rs | 1 + test_support/src/ninja_edge.rs | 41 +++ tests/ast_tests/console.rs | 11 +- tests/ast_tests/dependency_only.rs | 1 + tests/bdd/steps/ninja.rs | 13 +- tests/console_pool_runtime_tests.rs | 23 +- .../console_pool_runtime_tests/exclusivity.rs | 2 +- tests/documentation_examples_tests.rs | 7 +- tests/ninja_snapshot_tests.rs | 30 +-- 26 files changed, 840 insertions(+), 174 deletions(-) create mode 100644 src/observability/recorder/console.rs create mode 100644 src/observability/recorder/tests/console_tests.rs create mode 100644 src/runner/console_telemetry/mod.rs create mode 100644 src/runner/console_telemetry/tests.rs create mode 100644 test_support/src/ninja_edge.rs diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 2717b255d..372637554 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -7330,6 +7330,17 @@ The production generator has its own `netsuke::ninja_gen::GeneratedNinja` output type; `test_support::ninja_semantics::GeneratedNinja` is the test-side borrowed view of generated text. +### `test_support::ninja_edge` + +`test_support::ninja_edge::edge_has_console_pool` +(`test_support/src/ninja_edge.rs`) checks edge-local pool metadata on a named +generated `build` statement. Snapshot, BDD, and documentation-example tests use +this shared helper so their assertions agree about which edge owns an indented +`pool = console` binding. Its scope is deliberately narrow: it inspects the +generated text used by these tests and does not parse general Ninja syntax. +Permitted call sites are test-support and test code only; production code must +not depend on `test_support`. + ### Temporary Ninja build files `runner::process::create_temp_ninja_file` writes, flushes, and synchronizes a @@ -10269,6 +10280,37 @@ no fixture sentinel text. The governing decision record is which forbids unbounded or caller-controlled values in metric labels and trace fields. +### Module: `runner::console_telemetry` + +`src/runner/console_telemetry.rs` instruments only the actual Ninja build +invocation in `execute_build`. It supplements the complete legacy-recipe +operation timing with subprocess duration and outcome grouped by +`console_scheduling=enabled|disabled`. Its `runner.ninja.build` span and +completion event include the aggregate `console_edge_count`, scheduling state, +`outcome=success|error`, and `error_category=none|io`. Paths, action +identities, recipe contents, and process error text never enter this boundary. + +The generator stores the count of selected user edges privately in +`GeneratedNinja`; the runner reads that aggregate before consuming the bundle. +Synthetic serial gates and dyndep sidecars do not contribute to it. The count +describes configured edges in each attempted build graph, rather than edges +that Ninja actually executed or time spent in its pool. The subprocess boundary +emits `netsuke_runner_ninja_build_executions_total`, +`netsuke_runner_ninja_build_execution_duration_seconds`, and +`netsuke_runner_ninja_build_console_edges_total`. The first two use scheduling, +outcome, and error-category labels; the edge counter uses only outcome. + +Bundle-generation telemetry records the same scheduling state and aggregate +count. `netsuke_ninja_dyndep_bundle_generations_total` and +`netsuke_ninja_dyndep_bundle_generation_duration_seconds` carry scheduling, +outcome, and the generator's fixed error category, including +`console_dependency_only` and `console_pool_conflict`. +`netsuke_ninja_dyndep_bundle_console_edges_total` counts configured console +edges by generation outcome. `src/observability/recorder/console.rs` owns the +production retention policy for these six series: it admits their exact kinds +and finite label sets, rejecting unknown values, extra labels, and missing +labels. Both emission and retention tests use local recorders. + ### Module: `runner::recipe_shell::telemetry` `src/runner/recipe_shell/telemetry/mod.rs` owns bounded observability for shell diff --git a/docs/v0-1-0-migration-guide.md b/docs/v0-1-0-migration-guide.md index 30cefc126..1ba2ad200 100644 --- a/docs/v0-1-0-migration-guide.md +++ b/docs/v0-1-0-migration-guide.md @@ -1,7 +1,27 @@ # Migrating to v0.1.0 -This guide covers the changes released in the v0.1.0 beta series, up to and -including v0.1.0-beta4. +## Who should read this + +Read this guide when upgrading within the v0.1.0 beta series. It records +changes through beta4 and migration notes for the next pre-release, beta5. +Manifest authors, source-build users, and Rust API callers should follow the +sections that match the features they use. + +## Impact at a glance + +| Priority | Area | What to do | +| ------------------ | ---------------------- | ------------------------------------------------------------------- | +| Required | Checkout source builds | Use the pinned Rust toolchain before building from a checkout. | +| Required | Linux checkout builds | Install the pinned `mold` linker before building from a checkout. | +| Review if enabled | Manifest scheduling | Opt in only where immediate console output is useful. | +| Required if called | Rust `Target` literals | Set the `description` and `console` fields explicitly. | +| Review if enabled | Existing pool choices | Avoid dependency-only entries and conflicting action pools. | +| Optional | Existing manifests | Omit `console` to retain ordinary scheduling; it defaults to false. | + +_Table 1: migration obligations for the beta series and console-pool option._ + +This guide covers the changes released in the v0.1.0 beta series through +v0.1.0-beta4, plus migration notes for the next pre-release. The v0.1.0-beta3 release added the injectable child environment (`CommandEnv`), the named Ninja request types, narrow process options (`NinjaProcessOptions`), @@ -24,6 +44,15 @@ Beta4 also adds the opt-in `netsuke --json help targets --edges` graph export. no changes. See the [graph export reference](users-guide.md#export-dependency-edges-and-raw-recipes). +The next pre-release (`v0.1.0-beta5`) adds the opt-in `console` field for +actions and targets. It selects Ninja's special console pool for that build +edge, which runs alone and holds back progress from other edges. Netsuke +forwards Ninja's output as it reads it outside JSON mode, but recipes receive a +pipe rather than a PTY and may buffer their own output. See the +[console-pool guidance][console]. + +[console]: users-guide.md#stream-long-running-output-with-the-console-pool + Most existing manifests remain compatible, and callers of the convenience wrappers retain their child-process behaviour. Manifests using Jinja `glob()` must use shell-inert matched paths. The cached configuration discovery API is a @@ -32,8 +61,9 @@ no action. Non-UTF-8 build-file and working-directory paths are rejected at their input boundary. Rust callers that construct `Target` with a struct literal must add the new -`description` field (set it to `None` or `Some(...)`); deserialized manifests -remain compatible. Callers constructing `NinjaBuildRequest` or +`description` field (set it to `None` or `Some(...)`) and the `console` field +(set it to `false` or `true`); deserialized manifests remain compatible because +`console` defaults to `false`. Callers constructing `NinjaBuildRequest` or `NinjaToolRequest` must replace `cli: &cli` with `options: &options`. Callers that read `BuildGraph::targets` must use the graph accessors instead; see [Resolve build-graph outputs through the accessors](#resolve-build-graph-outputs-through-the-accessors). @@ -163,21 +193,6 @@ loading. See the [resource budgets](users-guide.md#resource-budgets) section for the complete key, environment-variable, and flag reference, along with the failure diagnostic. -## Nothing to change for existing callers - -The convenience wrappers keep their child-process behaviour: the child inherits -the calling process's environment, and Ninja is resolved exactly as before. -Their program and build-file path parameters now use `&Utf8Path`, so callers -using `std::path::Path` must convert those values. A caller that constructs -`NinjaBuildRequest`/`NinjaToolRequest` directly must pass `options: &options` -and supply the required `stderr_mode: StderrMode` field. - -Existing callers that construct `VerboseTimingReporter::new` continue to -receive timing summaries on stderr. Callers that need to capture or redirect -those summaries can opt into `VerboseTimingReporter::with_writer`; the -[developers' guide](developers-guide.md#verbose-timing-sink) documents the -owned sink and completion-ordering contract. - ## Use UTF-8 manifest and working-directory paths The Ninja invocation chain accepts only UTF-8 paths. This includes the manifest @@ -639,6 +654,68 @@ for other byte sequences; `hash` and `digest` stay byte-oriented and accept any content. See the [users' guide](users-guide.md#configure-file-reading-limits) for the full policy and its diagnostics. +## At-a-glance changes + + +Table: documented v0.1.0 additions, including `netsuke help targets`, and their +impact + +| Area | Impact | Where to read more | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Convenience wrappers | Unchanged. `run_ninja` and `run_ninja_tool` behave exactly as before, inheriting the process environment. | [Users' guide](users-guide.md) | +| Child environment | New opt-in `netsuke::runner::CommandEnv` carries additive variable overrides and an injected `PATH` for Ninja child processes. | [Developers' guide](developers-guide.md#ninja-invocation-with-an-explicit-environment) | +| Request types | New `netsuke::runner::NinjaBuildRequest` and `netsuke::runner::NinjaToolRequest` name the program, `NinjaProcessOptions`, build file, targets or tool, a child environment, and a required `stderr_mode: StderrMode` policy for the `*_with` run functions. | [Developers' guide](developers-guide.md#ninja-invocation-with-an-explicit-environment) | +| Cached CLI configuration API | Breaking for callers of the unstable Rust API: use the opt-in cached discovery flow with `ConfigEnvProvider`; `ConfigStdEnvProvider` supplies process-backed access. | [Developers' guide](developers-guide.md#cached-configuration-merge) | +| Timing output | Existing `VerboseTimingReporter::new` keeps its stderr sink; Rust callers can opt into an owned `Write + Send` sink with `with_writer`. | [Developers' guide](developers-guide.md#verbose-timing-sink) | +| Glob expansion | Parent-relative patterns such as `glob('../shared/*.h')` now expand. The Jinja helper rejects matched paths that are not portable unquoted shell words. Metadata checks use a capability rooted at the pattern's longest literal directory prefix; missing or non-directory prefixes return no matches, and unresolvable symlink matches are skipped. | [Users' guide](users-guide.md) and [ADR-0010](adrs/adr-0010-scope-the-glob-metadata-capability-to-the-literal-prefix.md) | +| Executable discovery | **Breaking:** default `which` and `command_available` no longer recursively discover workspace executables when `PATH` is empty or unset. Opt in with `cwd_mode='workspace-recursive'` only for trusted workspaces. | [Users' guide](users-guide.md#template-helpers) | +| Command recipes | On Windows, legacy scalar commands, lists, and scripts use Windows PowerShell by default; YAML command lists remain opt-in, ordered, and fail-fast. | [Windows legacy recipe contract](users-guide.md#windows-legacy-recipe-contract) | +| Ninja text escaping | Write shell dollars normally. In `command:` and `script:` recipes, `$in`, `$out`, `$ins`, and `$outs` are shell variables, so Ninja emits their dollars doubled and the selected shell receives their ordinary single-dollar spelling. `{{ ins }}` and `{{ outs }}` are the only Netsuke path markers. A manifest that used script `$in` or `$out` for Netsuke path substitution must use `{{ ins }}` or `{{ outs }}` instead. Spaces in build and default-target paths are escaped for Ninja as a `$` followed by a space, so whitespace-containing outputs remain valid. Paths containing `\|` or control characters remain rejected, as do paths containing `$` or colon, which Ninja can escape but Netsuke does not yet accept. Emitted metadata is escaped separately: a literal dollar in a description, `depfile`, `deps`, or `pool` is doubled, so `depfile: $out.d` is written as `$$out.d` and Ninja reads the literal name. Newline, carriage-return, and NUL metadata values are rejected. | [Users' guide](users-guide.md#review-the-safety-boundary) | +| Manifest discovery | Optional target/action `description` values are shown by the new `netsuke help targets` command. Manifests without them and existing build output are unchanged. | [Users' guide](users-guide.md) | +| Serial dependencies | New opt-in `dependency_order: serial` runs an action or target's direct `deps` list in declaration order. | [Serial dependency ordering](users-guide.md#run-direct-dependencies-serially) | +| Graph export | New opt-in `netsuke --json help targets --edges` output exposes a versioned graph beside the existing catalogue; root-level `--json` must precede `help`; omitting `--edges` preserves current output, and manifests need no changes. | [Graph export reference](users-guide.md#export-dependency-edges-and-raw-recipes) | +| Fetch redirects | Every redirect destination is now evaluated against the network policy before it is requested, so a redirect can no longer reach a host, scheme, or address the policy refuses. Chains stop after five redirects, a repeated destination is refused as a loop, and URL credentials are removed when the origin changes. | [Users' guide](users-guide.md#network-fetch-policy) and [ADR-0023](adrs/adr-0023-revalidate-every-fetch-redirect.md) | +| Manifest environment access | New optional exact-name `env()` allow and block lists. Existing manifests retain default-allow behaviour when neither list is configured; an active allowlist enables default-deny and a block always wins. | [Users' guide](users-guide.md#control-manifest-environment-access) | +| File-reading filters | The `contents`, `linecount`, `hash`, and `digest` filters now read under one 8 MiB default byte budget; a symlink final component is rejected unless `follow_symlinks=true` opts in, while FIFOs and devices are rejected outright, and per-call `max_bytes` can only narrow the budget. | [Configure file reading limits](users-guide.md#configure-file-reading-limits) | +| Script shell variables | **Breaking:** `$in` and `$out` in `script:` recipes are shell variables, as they already were in `command:` recipes. Use `{{ ins }}` and `{{ outs }}` for Netsuke path substitution. | [Script shell variables](#replace-script-in-and-out-path-substitution) | +| Build graph | **Breaking for callers of the unstable Rust API:** each logical build edge is stored once and indexed by output through `EdgeId`; `BuildGraph::targets` is no longer public. | [Graph accessors](#resolve-build-graph-outputs-through-the-accessors) | + +## What does not need to change + +The convenience wrappers keep their child-process behaviour: the child inherits +the calling process's environment, and Ninja is resolved exactly as before. +Their program and build-file path parameters now use `&Utf8Path`, so callers +using `std::path::Path` must convert those values. A caller that constructs +`NinjaBuildRequest`/`NinjaToolRequest` directly must pass `options: &options` +and supply the required `stderr_mode: StderrMode` field. + +Existing callers that construct `VerboseTimingReporter::new` continue to +receive timing summaries on stderr. Callers that need to capture or redirect +those summaries can opt into `VerboseTimingReporter::with_writer`; the +[developers' guide](developers-guide.md#verbose-timing-sink) documents the +owned sink and completion-ordering contract. + +## Migration checklist + +- [ ] Install the pinned Rust toolchain before building from a checkout. +- [ ] On Linux, install the pinned linker before the first source build. +- [ ] Review the migration sections for manifest or API features in use. +- [ ] Before opting into `console`, use Ninja 1.5 or later and check that the + action does not request a conflicting custom pool or contention class. +- [ ] Add `console: false` or `console: true` to Rust `Target` struct literals. + +## Common errors and fixes + +- **Error:** `Dependency-only entries cannot select the console pool.` + - **Fix:** Remove `console: true` from any dependency-only target. +- **Error:** `Build edge 'build/stamp' cannot select the console pool because its + action is dependency-only.` + - **Fix:** Enable console scheduling only on an executable action or target. +- **Error:** `Build edge 'build/stamp' selects the console pool but its action + requests custom pool 'compile'.` + - **Fix:** Remove the conflicting action pool, or select `console` there only + when the action itself should use the console pool. + ## Diagnostics Ninja subprocess spans and warn events carry two bounded fields, diff --git a/locales/nl/messages.ftl b/locales/nl/messages.ftl index 395d35d0c..44be742f5 100644 --- a/locales/nl/messages.ftl +++ b/locales/nl/messages.ftl @@ -199,8 +199,8 @@ ir.invalid_command = Ongeldige interpolatie in de opdracht: { $snippet }. # Fouten bij het genereren van Ninja. ninja_gen.missing_action = De actie ‘{ $id }’ waarnaar een bouwtak verwijst, ontbreekt. -ninja_gen.console_dependency_only = De buildrand ‘{ $output }’ kan de consolepool niet selecteren, omdat de actie alleen afhankelijkheden bevat. -ninja_gen.console_pool_conflict = De buildrand ‘{ $output }’ selecteert de consolepool, maar de actie vraagt om de aangepaste pool ‘{ $pool }’. +ninja_gen.console_dependency_only = De bouwtak ‘{ $output }’ kan de consolepool niet selecteren, omdat de actie alleen afhankelijkheden bevat. +ninja_gen.console_pool_conflict = De bouwtak ‘{ $output }’ selecteert de consolepool, maar de actie vraagt om de aangepaste pool ‘{ $pool }’. ninja_gen.format = De uitvoer van het Ninja-manifest kon niet worden opgemaakt. ninja_gen.dyndep_files_required = Deze bewerking vereist een gegenereerde Ninja-bundel; gebruik `netsuke build`, `netsuke clean` of `netsuke generate` om de dyndep-bestanden te materialiseren. ninja_gen.reserved_output_path = Het pad '{ $path }' is gereserveerd voor de seriële afhankelijkheidsstatus van Netsuke. diff --git a/src/ir/graph/error.rs b/src/ir/graph/error.rs index cf1d2e4cc..09696770f 100644 --- a/src/ir/graph/error.rs +++ b/src/ir/graph/error.rs @@ -23,7 +23,7 @@ pub(crate) fn manifest_recipe_validation_message(detail: &'static str) -> Option .then(|| localization::message(keys::MANIFEST_CONSOLE_DEPENDENCY_ONLY)) } -/// Render a manifest validation diagnostic in the active locale when possible. +/// Render a manifest diagnostic in the active locale when available. fn invalid_manifest_message(message: &'static str) -> String { manifest_recipe_validation_message(message) .map_or_else(|| message.to_owned(), |localized| localized.to_string()) diff --git a/src/ir/mod.rs b/src/ir/mod.rs index c455a0ffc..83df9093d 100644 --- a/src/ir/mod.rs +++ b/src/ir/mod.rs @@ -1,8 +1,8 @@ //! Intermediate Representation structures. //! -//! This module defines the backend-agnostic build graph used by Netsuke after -//! validation. The IR mirrors the conceptual model of Ninja without embedding -//! any Ninja-specific syntax. +//! This module defines the validated build graph Netsuke passes to Ninja +//! generation. Recipe and command data contain no Ninja syntax; the deliberate +//! [`BuildEdge::console`] annotation selects Ninja's console pool per edge. //! //! # Examples //! diff --git a/src/ninja_gen/dyndep/bundle.rs b/src/ninja_gen/dyndep/bundle.rs index ba448d65c..eeb0ec003 100644 --- a/src/ninja_gen/dyndep/bundle.rs +++ b/src/ninja_gen/dyndep/bundle.rs @@ -46,9 +46,17 @@ pub struct GeneratedNinja { pub(super) build_file: String, /// Dyndep sidecars `build_file` requires, in generation order. pub(super) dyndep_files: Vec, + /// Number of user build edges selecting the console pool. + pub(super) console_edge_count: usize, } impl GeneratedNinja { + /// Return the console-edge aggregate retained for runner telemetry. + #[must_use] + pub(crate) const fn console_edge_count(&self) -> usize { + self.console_edge_count + } + /// Borrow the main Ninja build file text. #[must_use] pub fn build_file(&self) -> &str { diff --git a/src/ninja_gen/dyndep/mod.rs b/src/ninja_gen/dyndep/mod.rs index 4d81d6ad7..ed2fbddb0 100644 --- a/src/ninja_gen/dyndep/mod.rs +++ b/src/ninja_gen/dyndep/mod.rs @@ -155,10 +155,9 @@ fn generate_bundle_inner( reject_unsupported_path_characters(graph)?; reject_reserved_paths(graph)?; super::validation::validate_console_scheduling(graph)?; - let serial_present = graph_requires_dyndep(graph); let mut out = String::new(); - if serial_present { + if graph_requires_dyndep(graph) { writeln!(out, "ninja_required_version = 1.10\n")?; } @@ -175,6 +174,7 @@ fn generate_bundle_inner( Ok(GeneratedNinja { build_file: out, dyndep_files, + console_edge_count: graph.edges().filter(|edge| edge.console).count(), }) } diff --git a/src/ninja_gen/dyndep/tests/console.rs b/src/ninja_gen/dyndep/tests/console.rs index 137df38c2..776896f8e 100644 --- a/src/ninja_gen/dyndep/tests/console.rs +++ b/src/ninja_gen/dyndep/tests/console.rs @@ -12,6 +12,10 @@ fn console_serial_parent_does_not_mark_gates_or_sidecars() -> Result<()> { edge.console = true; let graph = graph_with_edge(edge); let bundle = generate_bundle(&graph)?; + ensure!( + bundle.console_edge_count() == 1, + "console telemetry must count the user edge without synthetic gates or sidecars" + ); let file = bundle.build_file(); let lines: Vec<_> = file.lines().collect(); let parent_line = lines diff --git a/src/ninja_gen/tests/console.rs b/src/ninja_gen/tests/console.rs index 7246cb800..3c96fb2de 100644 --- a/src/ninja_gen/tests/console.rs +++ b/src/ninja_gen/tests/console.rs @@ -1,7 +1,7 @@ //! Tests for edge-level Ninja console pool selection. use super::command_action; -use super::{NinjaGenError, generate, generate_bundle}; +use super::{NinjaGenError, generate, generate_bundle, generate_posix}; use crate::ir::{BuildEdge, BuildGraph, DependencyOrder}; use anyhow::{Result, ensure}; use camino::Utf8PathBuf; @@ -30,18 +30,17 @@ fn console_pool_is_edge_level_and_keeps_shared_action_rules() -> Result<()> { .expect("test graph output aliases must be unique"); } - let ninja = generate(&graph)?; - ensure!( - ninja.matches("rule shared\n").count() == 1, - "shared action must have one rule definition:\n{ninja}" - ); - ensure!( - ninja.contains("build console.out: shared\n pool = console\n\n"), - "console binding must be emitted on its edge:\n{ninja}" + let ninja = generate_posix(&graph)?; + let expected = concat!( + "rule shared\n", + " command = echo ready\n\n", + "build console.out: shared\n", + " pool = console\n\n", + "build ordinary.out: shared\n\n", ); ensure!( - ninja.contains("build ordinary.out: shared\n\n"), - "ordinary edge must not inherit console scheduling:\n{ninja}" + ninja == expected, + "expected Ninja manifest:\n{expected}\nactual:\n{ninja}" ); Ok(()) } @@ -70,7 +69,7 @@ fn ordinary_edge_clears_a_shared_console_action_pool() -> Result<()> { .expect("test graph output aliases must be unique"); } - let ninja = generate(&graph)?; + let ninja = generate_posix(&graph)?; ensure!( ninja.contains("pool = console\n\nbuild console.out: shared\n pool = console\n"), "selected edge must retain console scheduling:\n{ninja}" @@ -104,7 +103,7 @@ fn console_pool_accepts_matching_action_pool() -> Result<()> { }) .expect("test graph output aliases must be unique"); - let ninja = generate(&graph)?; + let ninja = generate_posix(&graph)?; ensure!( ninja.contains(" pool = console\n\nbuild console.out: shared\n pool = console\n\n"), "matching action and edge pools must both be accepted:\n{ninja}" diff --git a/src/observability/recorder/console.rs b/src/observability/recorder/console.rs new file mode 100644 index 000000000..4619d7bc3 --- /dev/null +++ b/src/observability/recorder/console.rs @@ -0,0 +1,66 @@ +//! Admit only the fixed console-scheduling generation and build metric series. + +use super::labels::exact_labels; +use metrics::Key; +use metrics_util::MetricKind; +use netsuke::runner::{ + BUNDLE_CONSOLE_EDGES_TOTAL, BUNDLE_GENERATION_DURATION, BUNDLE_GENERATIONS_TOTAL, + NINJA_BUILD_CONSOLE_EDGES_TOTAL, NINJA_BUILD_EXECUTION_DURATION, NINJA_BUILD_EXECUTIONS_TOTAL, +}; + +/// Bound scheduling labels to the presence of selected console edges. +const SCHEDULING: &[&str] = &["enabled", "disabled"]; +/// Bound operation outcomes to completed successes and errors. +const OUTCOMES: &[&str] = &["success", "error"]; +/// Bound Ninja invocation errors without retaining process details. +const BUILD_ERRORS: &[&str] = &["none", "io"]; +/// Bound bundle errors to the generator's fixed classification vocabulary. +const GENERATION_ERRORS: &[&str] = &[ + "none", + "missing_action", + "console_dependency_only", + "console_pool_conflict", + "command_list", + "unsafe_ninja_value", + "power_shell_recipe_too_large", + "format", + "dyndep_files_required", + "reserved_output_path", + "unsupported_path_character", + "unsafe_ninja_path", +]; + +/// Report whether a description belongs to the console-scheduling metric set. +pub(super) fn is_console_metric(name: &str) -> bool { + matches!( + name, + BUNDLE_CONSOLE_EDGES_TOTAL + | BUNDLE_GENERATION_DURATION + | BUNDLE_GENERATIONS_TOTAL + | NINJA_BUILD_CONSOLE_EDGES_TOTAL + | NINJA_BUILD_EXECUTION_DURATION + | NINJA_BUILD_EXECUTIONS_TOTAL + ) +} + +/// Admit only the metric kind and exact finite labels emitted by the runner. +pub(super) fn accepts_registration(key: &Key, kind: MetricKind) -> bool { + let errors = match (key.name(), kind) { + (BUNDLE_CONSOLE_EDGES_TOTAL | NINJA_BUILD_CONSOLE_EDGES_TOTAL, MetricKind::Counter) => { + return exact_labels(key, &[("outcome", OUTCOMES)]); + } + (BUNDLE_GENERATIONS_TOTAL, MetricKind::Counter) + | (BUNDLE_GENERATION_DURATION, MetricKind::Histogram) => GENERATION_ERRORS, + (NINJA_BUILD_EXECUTIONS_TOTAL, MetricKind::Counter) + | (NINJA_BUILD_EXECUTION_DURATION, MetricKind::Histogram) => BUILD_ERRORS, + _ => return false, + }; + exact_labels( + key, + &[ + ("outcome", OUTCOMES), + ("error_category", errors), + ("console_scheduling", SCHEDULING), + ], + ) +} diff --git a/src/observability/recorder/mod.rs b/src/observability/recorder/mod.rs index 500cc4590..35fbd2f78 100644 --- a/src/observability/recorder/mod.rs +++ b/src/observability/recorder/mod.rs @@ -9,6 +9,7 @@ //! retained snapshot. mod check; +mod console; mod labels; #[cfg(feature = "lint")] @@ -155,6 +156,7 @@ impl ConfigMetricsRecorder { | NINJA_STATUS_OVERSIZED_LINES_TOTAL | SHELL_QUOTE_DIALECT_TOTAL ) || is_check_metric(name) + || console::is_console_metric(name) } /// Admit exact bounded counter series by their registered name. @@ -216,7 +218,7 @@ impl ConfigMetricsRecorder { | SHELL_QUOTE_DIALECT_TOTAL => accepts_stdlib_counter_registration(key), #[cfg(feature = "lint")] CHECK_TOTAL => exact_labels(key, &[(OUTCOME_LABEL, &CHECK_OUTCOMES)]), - _ => false, + _ => console::accepts_registration(key, MetricKind::Counter), } } @@ -238,7 +240,7 @@ impl ConfigMetricsRecorder { ), #[cfg(feature = "lint")] CHECK_DURATION => exact_labels(key, &[(OUTCOME_LABEL, &CHECK_OUTCOMES)]), - _ => false, + _ => console::accepts_registration(key, MetricKind::Histogram), } } diff --git a/src/observability/recorder/tests/console_tests.rs b/src/observability/recorder/tests/console_tests.rs new file mode 100644 index 000000000..635752331 --- /dev/null +++ b/src/observability/recorder/tests/console_tests.rs @@ -0,0 +1,89 @@ +//! Verify production snapshots retain only bounded console-scheduling series. + +use super::{ConfigMetricsRecorder, SnapshotEntry}; +use metrics::{counter, gauge, histogram}; +use metrics_util::{MetricKind, debugging::DebugValue}; +use netsuke::runner::{ + BUNDLE_CONSOLE_EDGES_TOTAL, BUNDLE_GENERATION_DURATION, BUNDLE_GENERATIONS_TOTAL, + NINJA_BUILD_CONSOLE_EDGES_TOTAL, NINJA_BUILD_EXECUTION_DURATION, NINJA_BUILD_EXECUTIONS_TOTAL, +}; + +/// Emit valid series and adversarial variants through the application recorder. +fn record_console_series() { + for name in [BUNDLE_GENERATIONS_TOTAL, NINJA_BUILD_EXECUTIONS_TOTAL] { + counter!(name, "outcome" => "success", "error_category" => "none", "console_scheduling" => "enabled").increment(1); + counter!(name, "console_scheduling" => "private-output", "outcome" => "success", "error_category" => "none").increment(1); + counter!(name, "console_scheduling" => "enabled", "outcome" => "success", "error_category" => "none", "output" => "private-output").increment(1); + counter!(name, "outcome" => "success", "error_category" => "none").increment(1); + gauge!(name, "console_scheduling" => "enabled", "outcome" => "success", "error_category" => "none").set(1.0); + } + for name in [BUNDLE_GENERATION_DURATION, NINJA_BUILD_EXECUTION_DURATION] { + histogram!(name, "outcome" => "error", "error_category" => if name == BUNDLE_GENERATION_DURATION { "console_pool_conflict" } else { "io" }, "console_scheduling" => "disabled").record(0.25); + histogram!(name, "console_scheduling" => "disabled", "outcome" => "error", "error_category" => "private-process-error").record(0.25); + } + for name in [BUNDLE_CONSOLE_EDGES_TOTAL, NINJA_BUILD_CONSOLE_EDGES_TOTAL] { + counter!(name, "outcome" => "success").increment(2); + counter!(name, "outcome" => "success", "edge" => "private-output").increment(2); + counter!(name, "outcome" => "private-outcome").increment(2); + } +} + +/// Find a retained counter with its exact expected value. +fn has_counter(snapshot: &[SnapshotEntry], name: &str, expected: u64) -> bool { + snapshot.iter().any(|entry| { + entry.0.kind() == MetricKind::Counter + && entry.0.key().name() == name + && matches!(&entry.3, DebugValue::Counter(value) if *value == expected) + }) +} + +/// Find a retained histogram containing the single expected sample. +fn has_single_sample_histogram(snapshot: &[SnapshotEntry], name: &str) -> bool { + snapshot.iter().any(|entry| { + entry.0.kind() == MetricKind::Histogram + && entry.0.key().name() == name + && matches!(&entry.3, DebugValue::Histogram(samples) if samples.len() == 1) + }) +} + +/// Check that the retained snapshot contains only bounded console series. +fn assert_bounded_console_snapshot(snapshot: &[SnapshotEntry]) { + assert_eq!( + snapshot.len(), + 6, + "only the six exact bounded series survive: {snapshot:?}" + ); + for (name, expected) in [ + (BUNDLE_GENERATIONS_TOTAL, 1), + (NINJA_BUILD_EXECUTIONS_TOTAL, 1), + (BUNDLE_CONSOLE_EDGES_TOTAL, 2), + (NINJA_BUILD_CONSOLE_EDGES_TOTAL, 2), + ] { + assert!( + has_counter(snapshot, name, expected), + "snapshot must retain {name} with value {expected}: {snapshot:?}" + ); + } + for name in [BUNDLE_GENERATION_DURATION, NINJA_BUILD_EXECUTION_DURATION] { + assert!( + has_single_sample_histogram(snapshot, name), + "snapshot must retain one sample for {name}: {snapshot:?}" + ); + } + assert!(snapshot.iter().all(|entry| { + entry + .0 + .key() + .labels() + .all(|label| !label.value().contains("private")) + })); +} + +#[test] +fn recorder_retains_exact_bounded_console_scheduling_series() { + let recorder = ConfigMetricsRecorder::new(); + let snapshotter = recorder.snapshotter(); + metrics::with_local_recorder(&recorder, record_console_series); + let snapshot = snapshotter.snapshot().into_vec(); + assert_bounded_console_snapshot(&snapshot); +} diff --git a/src/observability/recorder/tests/mod.rs b/src/observability/recorder/tests/mod.rs index 1bfa9eeb6..32934a2c6 100644 --- a/src/observability/recorder/tests/mod.rs +++ b/src/observability/recorder/tests/mod.rs @@ -17,6 +17,8 @@ type SnapshotEntry = (CompositeKey, Option, Option, DebugVal /// Fixed three-label layout used by recipe-shell counters. type MetricLabels = [(&'static str, &'static str); 3]; +/// Cover retained console-scheduling generation and build metrics separately. +mod console_tests; /// Cover bounded legacy-recipe operation metric registrations separately. mod legacy_recipe_tests; diff --git a/src/runner/console_telemetry/mod.rs b/src/runner/console_telemetry/mod.rs new file mode 100644 index 000000000..b79856e2d --- /dev/null +++ b/src/runner/console_telemetry/mod.rs @@ -0,0 +1,116 @@ +//! Record bounded console-scheduling telemetry around actual Ninja builds. +//! +//! This runner-only boundary accepts a graph aggregate and an invocation +//! closure. It emits no paths, identifiers, commands, or process error text. + +use metrics::{counter, describe_counter, describe_histogram, histogram}; +use std::{ + io, + sync::Once, + time::{Duration, Instant}, +}; +use tracing::field; + +/// Metric counting actual runner-owned Ninja build invocations. +pub const NINJA_BUILD_EXECUTIONS_TOTAL: &str = "netsuke_runner_ninja_build_executions_total"; +/// Metric measuring actual runner-owned Ninja build duration in seconds. +pub const NINJA_BUILD_EXECUTION_DURATION: &str = + "netsuke_runner_ninja_build_execution_duration_seconds"; +/// Metric counting selected console edges across attempted Ninja build invocations. +pub const NINJA_BUILD_CONSOLE_EDGES_TOTAL: &str = "netsuke_runner_ninja_build_console_edges_total"; + +/// Return the fixed scheduling label for one aggregate console-edge count. +pub(in crate::runner) const fn scheduling_label(console_edge_count: usize) -> &'static str { + if console_edge_count == 0 { + "disabled" + } else { + "enabled" + } +} + +/// Record the outcome and duration of one actual Ninja build invocation. +/// +/// Count configured user edges in the attempted build graph, rather than +/// inferring which edges Ninja actually executed or how long they occupied its pool. +/// +/// # Errors +/// +/// Returns the invocation's I/O error unchanged when Ninja execution fails. +pub(in crate::runner) fn instrument_ninja_build( + console_edge_count: usize, + invoke: impl FnOnce() -> io::Result, +) -> io::Result { + describe_metrics(); + let console_scheduling = scheduling_label(console_edge_count); + let span = tracing::info_span!( + "runner.ninja.build", + console_edge_count, + console_scheduling, + outcome = field::Empty, + error_category = field::Empty, + ); + let _guard = span.enter(); + let started = Instant::now(); + let result = invoke(); + record_result(&span, console_edge_count, started.elapsed(), &result); + result +} + +/// Emit the bounded result, duration, and configured-edge aggregate. +fn record_result( + span: &tracing::Span, + console_edge_count: usize, + elapsed: Duration, + result: &io::Result, +) { + let console_scheduling = scheduling_label(console_edge_count); + let (outcome, error_category) = if result.is_ok() { + ("success", "none") + } else { + ("error", "io") + }; + span.record("outcome", outcome); + span.record("error_category", error_category); + tracing::info!( + console_edge_count, + console_scheduling, + outcome, + error_category, + "Completed Ninja build invocation" + ); + counter!(NINJA_BUILD_EXECUTIONS_TOTAL, + "outcome" => outcome, "error_category" => error_category, + "console_scheduling" => console_scheduling) + .increment(1); + histogram!(NINJA_BUILD_EXECUTION_DURATION, + "outcome" => outcome, "error_category" => error_category, + "console_scheduling" => console_scheduling) + .record(elapsed); + counter!(NINJA_BUILD_CONSOLE_EDGES_TOTAL, "outcome" => outcome) + .increment(u64::try_from(console_edge_count).unwrap_or(u64::MAX)); +} + +/// Register the fixed Ninja-build metric descriptions once per process. +fn describe_metrics() { + static DESCRIBE: Once = Once::new(); + DESCRIBE.call_once(|| { + describe_counter!( + NINJA_BUILD_EXECUTIONS_TOTAL, + "Counts runner-owned Ninja builds by console scheduling, outcome, and error category." + ); + describe_histogram!( + NINJA_BUILD_EXECUTION_DURATION, + "Measures Ninja build duration in seconds by console scheduling, outcome, and error category." + ); + describe_counter!( + NINJA_BUILD_CONSOLE_EDGES_TOTAL, + concat!( + "Counts configured console edges across attempted runner-owned builds; ", + "it does not count executed edges." + ) + ); + }); +} + +#[cfg(test)] +mod tests; diff --git a/src/runner/console_telemetry/tests.rs b/src/runner/console_telemetry/tests.rs new file mode 100644 index 000000000..b1b25b9a7 --- /dev/null +++ b/src/runner/console_telemetry/tests.rs @@ -0,0 +1,117 @@ +//! Verify bounded console-scheduling metrics for actual Ninja invocations. + +use super::*; +use crate::test_tracing_capture::with_test_subscriber; +use metrics_util::{ + CompositeKey, MetricKind, + debugging::{DebugValue, DebuggingRecorder}, +}; +use rstest::rstest; +use tracing_subscriber::filter::LevelFilter; + +/// Represent the local recorder's drained metric series. +type Snapshot = Vec<( + CompositeKey, + Option, + Option, + DebugValue, +)>; + +/// Find one named metric with exactly the expected bounded labels. +fn metric<'a>( + snapshot: &'a Snapshot, + name: &str, + kind: MetricKind, + labels: &[(&str, &str)], +) -> Option<&'a DebugValue> { + snapshot.iter().find_map(|(key, _, _, value)| { + let actual = key + .key() + .labels() + .map(|label| (label.key(), label.value())) + .collect::>(); + (key.kind() == kind + && key.key().name() == name + && actual.len() == labels.len() + && labels.iter().all(|label| actual.contains(label))) + .then_some(value) + }) +} + +/// Assert one bounded operation, one duration sample, and the configured-edge count. +fn assert_metrics(snapshot: &Snapshot, console_edges: usize, labels: [(&str, &str); 3]) { + assert_eq!( + snapshot.len(), + 3, + "only three bounded series are emitted: {snapshot:?}" + ); + assert!(matches!( + metric( + snapshot, + NINJA_BUILD_EXECUTIONS_TOTAL, + MetricKind::Counter, + &labels + ), + Some(DebugValue::Counter(1)) + )); + assert!( + matches!(metric(snapshot, NINJA_BUILD_EXECUTION_DURATION, MetricKind::Histogram, &labels), Some(DebugValue::Histogram(samples)) if samples.len() == 1) + ); + let outcome = labels[1].1; + assert!( + matches!(metric(snapshot, NINJA_BUILD_CONSOLE_EDGES_TOTAL, MetricKind::Counter, &[("outcome", outcome)]), Some(DebugValue::Counter(count)) if *count == u64::try_from(console_edges).expect("small fixture count")) + ); +} + +#[rstest] +#[case::ordinary_success(0, false)] +#[case::console_success(2, false)] +#[case::ordinary_failure(0, true)] +#[case::console_failure(2, true)] +fn invocation_records_bounded_scheduling_outcome_and_duration( + #[case] console_edges: usize, + #[case] fail: bool, +) { + let scheduling = scheduling_label(console_edges); + let (outcome, category) = if fail { + ("error", "io") + } else { + ("success", "none") + }; + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let (result, events) = metrics::with_local_recorder(&recorder, || { + with_test_subscriber(LevelFilter::INFO, |captured| { + let result = instrument_ninja_build(console_edges, || { + if fail { + Err(io::Error::other("private process detail")) + } else { + Ok(()) + } + }); + (result, captured.snapshot()) + }) + }); + assert_eq!(result.is_err(), fail); + let snapshot = snapshotter.snapshot().into_vec(); + let labels = [ + ("console_scheduling", scheduling), + ("outcome", outcome), + ("error_category", category), + ]; + assert_metrics(&snapshot, console_edges, labels); + assert!( + events.iter().any(|event| { + event.contains(&format!("console_edge_count={console_edges}")) + && event.contains(&format!("console_scheduling={scheduling:?}")) + && event.contains(&format!("outcome={outcome:?}")) + && event.contains(&format!("error_category={category:?}")) + }), + "build event must expose the aggregate and fixed outcomes: {events:?}" + ); + assert!( + events + .iter() + .all(|event| !event.contains("private process detail")) + ); +} diff --git a/src/runner/dyndep/generation_telemetry.rs b/src/runner/dyndep/generation_telemetry.rs index 32cd7ee21..ffe1603f1 100644 --- a/src/runner/dyndep/generation_telemetry.rs +++ b/src/runner/dyndep/generation_telemetry.rs @@ -12,9 +12,12 @@ use std::{sync::Once, time::Instant}; use tracing::field; /// Metric name counting runner-owned bundle generation outcomes. -const BUNDLE_GENERATIONS_TOTAL: &str = "netsuke_ninja_dyndep_bundle_generations_total"; +pub const BUNDLE_GENERATIONS_TOTAL: &str = "netsuke_ninja_dyndep_bundle_generations_total"; /// Metric name measuring runner-owned bundle generation duration in seconds. -const BUNDLE_GENERATION_DURATION: &str = "netsuke_ninja_dyndep_bundle_generation_duration_seconds"; +pub const BUNDLE_GENERATION_DURATION: &str = + "netsuke_ninja_dyndep_bundle_generation_duration_seconds"; +/// Metric counting configured console edges across bundle-generation attempts. +pub const BUNDLE_CONSOLE_EDGES_TOTAL: &str = "netsuke_ninja_dyndep_bundle_console_edges_total"; /// Record one runner-owned bundle-generation operation. pub(in crate::runner) fn instrument_bundle_generation( @@ -29,36 +32,50 @@ pub(in crate::runner) fn instrument_bundle_generation( // Count logical build operations once; `output_count` measures aliases and // would inflate this span for a single multi-output edge. let canonical_target_count = graph.edge_count(); + let console_edge_count = graph.edges().filter(|edge| edge.console).count(); + let console_scheduling = crate::runner::console_telemetry::scheduling_label(console_edge_count); let span = tracing::trace_span!( "runner.ninja.dyndep_bundle.generate", action_count = graph.actions.len(), target_count = canonical_target_count, dependency_count, + console_edge_count, + console_scheduling, outcome = field::Empty, error_category = field::Empty, ); let _guard = span.enter(); let started = Instant::now(); let result = generate(); - let outcome = record_outcome(&span, &result); - counter!(BUNDLE_GENERATIONS_TOTAL, "outcome" => outcome).increment(1); - histogram!(BUNDLE_GENERATION_DURATION).record(started.elapsed()); + let (outcome, error_category) = record_outcome(&span, &result); + counter!(BUNDLE_GENERATIONS_TOTAL, "outcome" => outcome, + "error_category" => error_category, "console_scheduling" => console_scheduling) + .increment(1); + histogram!(BUNDLE_GENERATION_DURATION, "outcome" => outcome, + "error_category" => error_category, "console_scheduling" => console_scheduling) + .record(started.elapsed()); + counter!(BUNDLE_CONSOLE_EDGES_TOTAL, "outcome" => outcome) + .increment(u64::try_from(console_edge_count).unwrap_or(u64::MAX)); result } /// Record the bounded outcome and error category on the span and return it. -fn record_outcome(span: &tracing::Span, result: &Result) -> &'static str { +fn record_outcome( + span: &tracing::Span, + result: &Result, +) -> (&'static str, &'static str) { match result { Ok(_) => { span.record("outcome", "success"); - "success" + span.record("error_category", "none"); + ("success", "none") } Err(error) => { let category = error_category(error); span.record("outcome", "error"); span.record("error_category", category); tracing::debug!(error_category = category, "dyndep bundle generation failed"); - "error" + ("error", category) } } } @@ -92,11 +109,15 @@ fn describe_metrics() { DESCRIBE.call_once(|| { describe_counter!( BUNDLE_GENERATIONS_TOTAL, - "Counts runner-owned dyndep bundle generation outcomes." + "Counts bundle-generation outcomes by bounded console-scheduling state and error category." ); describe_histogram!( BUNDLE_GENERATION_DURATION, - "Measures runner-owned dyndep bundle generation duration in seconds." + "Measures bundle-generation duration by bounded outcome, error category, and console-scheduling state." + ); + describe_counter!( + BUNDLE_CONSOLE_EDGES_TOTAL, + "Counts configured console edges across runner-owned bundle-generation attempts." ); }); } @@ -106,13 +127,16 @@ mod tests { //! Tests for bounded generation telemetry at the runner boundary. use super::*; + use crate::ir::{BuildEdge, DependencyOrder}; + use camino::Utf8PathBuf; use metrics_util::MetricKind; use metrics_util::debugging::{DebugValue, DebuggingRecorder}; + use rstest::rstest; use std::sync::{Arc, Mutex, PoisonError}; use tracing::{ Subscriber, field::{Field, Visit}, - span::{Id, Record}, + span::{Attributes, Id, Record}, }; use tracing_subscriber::{ Layer, filter::LevelFilter, layer::Context as LayerContext, prelude::*, @@ -137,6 +161,15 @@ mod tests { where S: Subscriber + for<'span> LookupSpan<'span>, { + fn on_new_span(&self, values: &Attributes<'_>, _span: &Id, _ctx: LayerContext<'_, S>) { + let mut visitor = SpanFieldVisitor::default(); + values.record(&mut visitor); + self.fields + .lock() + .unwrap_or_else(PoisonError::into_inner) + .extend(visitor.fields); + } + fn on_record(&self, _span: &Id, values: &Record<'_>, _ctx: LayerContext<'_, S>) { let mut visitor = SpanFieldVisitor::default(); values.record(&mut visitor); @@ -170,69 +203,151 @@ mod tests { tracing::subscriber::with_default(subscriber, || test(captured)) } - fn bundle_generation_count( - snapshot: &[( - metrics_util::CompositeKey, - Option, - Option, - DebugValue, - )], - outcome: &str, - ) -> Option { + /// Represent one locally captured metric snapshot. + type Snapshot = Vec<( + metrics_util::CompositeKey, + Option, + Option, + DebugValue, + )>; + + /// Find a metric with the exact finite label set. + fn metric<'a>( + snapshot: &'a Snapshot, + name: &str, + kind: MetricKind, + labels: &[(&str, &str)], + ) -> Option<&'a DebugValue> { snapshot.iter().find_map(|(key, _, _, value)| { - let has_outcome = key + let actual = key .key() .labels() - .any(|label| label.key() == "outcome" && label.value() == outcome); - match (key.kind(), key.key().name(), value) { - (MetricKind::Counter, BUNDLE_GENERATIONS_TOTAL, DebugValue::Counter(count)) - if has_outcome => - { - Some(*count) - } - _ => None, - } + .map(|label| (label.key(), label.value())) + .collect::>(); + (key.kind() == kind + && key.key().name() == name + && actual.len() == labels.len() + && labels.iter().all(|label| actual.contains(label))) + .then_some(value) }) } - fn record_generation( - recorder: &DebuggingRecorder, - graph: &BuildGraph, - generate: impl FnOnce() -> Result, - ) -> Result { - metrics::with_local_recorder(recorder, || instrument_bundle_generation(graph, generate)) + /// Construct graph shape without invoking the generator under observation. + fn graph_with_console_edges(console_edges: usize) -> BuildGraph { + let mut graph = BuildGraph::default(); + for index in 0..=console_edges { + graph + .insert_edge(BuildEdge { + action_id: "shape-only".into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec![Utf8PathBuf::from(format!("private-{index}.out"))], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + console: index < console_edges, + }) + .expect("fixture aliases must be distinct"); + } + graph + } + + /// Build the controlled console error used to verify finite categories. + fn console_error(category: &str) -> NinjaGenError { + use crate::localization::{self, keys}; + match category { + "console_dependency_only" => NinjaGenError::ConsoleDependencyOnly { + output: "private-output".into(), + message: localization::message(keys::NINJA_GEN_CONSOLE_DEPENDENCY_ONLY) + .with_arg("output", "private-output"), + }, + "console_pool_conflict" => NinjaGenError::ConsolePoolConflict { + output: "private-output".into(), + pool: "private-pool".into(), + message: localization::message(keys::NINJA_GEN_CONSOLE_POOL_CONFLICT) + .with_arg("output", "private-output") + .with_arg("pool", "private-pool"), + }, + _ => panic!("fixture must name a known category"), + } } - #[test] - fn runner_boundary_records_bundle_generation() { + /// Assert one bounded operation, one duration sample, and the configured-edge count. + fn assert_metrics(snapshot: &Snapshot, console_edges: usize, labels: [(&str, &str); 3]) { + assert_eq!( + snapshot.len(), + 3, + "only three bounded series are emitted: {snapshot:?}" + ); + assert!(matches!( + metric( + snapshot, + BUNDLE_GENERATIONS_TOTAL, + MetricKind::Counter, + &labels + ), + Some(DebugValue::Counter(1)) + )); + assert!( + matches!(metric(snapshot, BUNDLE_GENERATION_DURATION, MetricKind::Histogram, &labels), Some(DebugValue::Histogram(samples)) if samples.len() == 1) + ); + let outcome = labels[0].1; + assert!( + matches!(metric(snapshot, BUNDLE_CONSOLE_EDGES_TOTAL, MetricKind::Counter, &[("outcome", outcome)]), Some(DebugValue::Counter(count)) if *count == u64::try_from(console_edges).expect("small fixture count")) + ); + } + + #[rstest] + #[case::ordinary_success(0, "disabled", "none")] + #[case::console_success(2, "enabled", "none")] + #[case::dependency_only_error(2, "enabled", "console_dependency_only")] + #[case::pool_conflict_error(2, "enabled", "console_pool_conflict")] + fn generation_records_console_shape_and_bounded_outcomes( + #[case] console_edges: usize, + #[case] scheduling: &str, + #[case] category: &str, + ) { let recorder = DebuggingRecorder::new(); let snapshotter = recorder.snapshotter(); - let graph = BuildGraph::default(); - let (success, error, span_fields) = with_span_capture(|captured| { - let success = record_generation(&recorder, &graph, || Ok::<_, NinjaGenError>(())); - let error = record_generation(&recorder, &graph, || { - Err::<(), _>(NinjaGenError::EmptyCommandRecipe { action_index: 1 }) + let graph = graph_with_console_edges(console_edges); + let (result, fields) = with_span_capture(|captured| { + let result = metrics::with_local_recorder(&recorder, || { + instrument_bundle_generation(&graph, || { + if category == "none" { + Ok(()) + } else { + Err(console_error(category)) + } + }) }); - (success, error, captured.snapshot()) + (result, captured.snapshot()) }); - assert!(success.is_ok()); - assert!(matches!( - error, - Err(NinjaGenError::EmptyCommandRecipe { action_index: 1 }) - )); + let outcome = if category == "none" { + "success" + } else { + "error" + }; + assert_eq!(result.is_err(), category != "none"); let snapshot = snapshotter.snapshot().into_vec(); - assert_eq!(bundle_generation_count(&snapshot, "success"), Some(1)); - assert_eq!(bundle_generation_count(&snapshot, "error"), Some(1)); - assert!(snapshot.iter().any(|(key, _, _, value)| { - key.kind() == MetricKind::Histogram - && key.key().name() == BUNDLE_GENERATION_DURATION - && matches!(value, DebugValue::Histogram(samples) if samples.len() == 2) - })); - assert!( - span_fields - .iter() - .any(|field| field == "error_category=\"command_list\""), - "the error span must record the bounded command-list category: {span_fields:?}" - ); + let labels = [ + ("outcome", outcome), + ("error_category", category), + ("console_scheduling", scheduling), + ]; + assert_metrics(&snapshot, console_edges, labels); + for expected in [ + format!("console_edge_count={console_edges}"), + format!("console_scheduling={scheduling:?}"), + format!("outcome={outcome:?}"), + format!("error_category={category:?}"), + ] { + assert!( + fields.contains(&expected), + "span must record {expected}: {fields:?}" + ); + } + assert!(fields.iter().all(|field| !field.contains("private-"))); } } diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 8971fb049..e19606c43 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -7,6 +7,7 @@ mod callsite_interest_reproduction; #[cfg(feature = "lint")] mod check; +mod console_telemetry; mod dispatch; mod dyndep; mod error; @@ -26,6 +27,12 @@ pub use camino::{Utf8Path, Utf8PathBuf}; pub use check::diagnostics as check_diagnostics; #[cfg(feature = "lint")] pub use check::diagnostics::FindingDiagnostic; +pub use console_telemetry::{ + NINJA_BUILD_CONSOLE_EDGES_TOTAL, NINJA_BUILD_EXECUTION_DURATION, NINJA_BUILD_EXECUTIONS_TOTAL, +}; +pub use dyndep::generation_telemetry::{ + BUNDLE_CONSOLE_EDGES_TOTAL, BUNDLE_GENERATION_DURATION, BUNDLE_GENERATIONS_TOTAL, +}; pub use error::RunnerError; use monotony::StdMonotonicClock; use std::io::IsTerminal; @@ -223,6 +230,7 @@ fn execute_build( graph_generation, )?; let (bundle, command_list_targets) = generated.into_build_parts(); + let console_edge_count = bundle.console_edge_count(); let publication = materialize_dyndep_bundle(cli, &bundle)?; prune_dyndep_bundle(cli, bundle.dyndep_files(), &publication)?; let ninja = NinjaContent::new(bundle.into_parts().0); @@ -234,13 +242,9 @@ fn execute_build( let build_file = process::create_temp_ninja_file(&ninja)?; let build_path = build_file.as_path(); - let ctx = || { - format!( - "running {} with build file {}", - context.ninja_program, build_path - ) - }; - let options = ninja::ninja_process_options(cli)?; + let ninja_program = context.ninja_program; + let ctx = || format!("running {ninja_program} with build file {build_path}"); + let options = ninja::ninja_process_options(cli).with_context(ctx)?; let env = CommandEnv::inherit(); let build_request = process::NinjaBuildRequest { program: context.ninja_program, @@ -250,16 +254,18 @@ fn execute_build( env: &env, stderr_mode: StderrMode::from_json_enabled(cli.json), }; - let ninja_result = if context.progress_enabled { - let mut on_task_progress = on_task_progress_callback(context.reporter); - process::run_ninja_with_command_list_targets( - build_request, - Some(&mut on_task_progress), - &command_list_targets, - ) - } else { - ninja::run_ninja_with_command_list_targets(build_request, &command_list_targets) - }; + let ninja_result = console_telemetry::instrument_ninja_build(console_edge_count, || { + if context.progress_enabled { + let mut on_task_progress = on_task_progress_callback(context.reporter); + process::run_ninja_with_command_list_targets( + build_request, + Some(&mut on_task_progress), + &command_list_targets, + ) + } else { + ninja::run_ninja_with_command_list_targets(build_request, &command_list_targets) + } + }); ninja_result.map_err(|error| { // Keep marker text in the CLI's outer context. let error_context = if error.to_string().contains(COMMAND_LIST_FAILURE_PREFIX) { diff --git a/test_support/src/lib.rs b/test_support/src/lib.rs index 82e75da16..308ddbfa1 100644 --- a/test_support/src/lib.rs +++ b/test_support/src/lib.rs @@ -32,6 +32,7 @@ pub mod localizer; pub mod manifest; pub mod netsuke; pub mod ninja; +pub mod ninja_edge; pub mod ninja_gen; pub mod ninja_semantics; pub mod stdlib_assert; diff --git a/test_support/src/ninja_edge.rs b/test_support/src/ninja_edge.rs new file mode 100644 index 000000000..9c56eae47 --- /dev/null +++ b/test_support/src/ninja_edge.rs @@ -0,0 +1,41 @@ +//! Inspect named build-edge bindings in generated Ninja text. +//! +//! This module gives tests one shared way to check edge-local metadata such as +//! `pool = console`. Keep it limited to simple generated-manifest assertions; +//! parsing arbitrary Ninja syntax belongs in a real parser. + +use anyhow::{Context, Result}; + +/// Return whether the named output edge selects Ninja's console pool. +/// +/// The lookup is scoped to the selected `build` statement, so a pool binding +/// inherited from a rule or attached to a neighbouring edge cannot satisfy the +/// assertion. +/// +/// # Examples +/// +/// ``` +/// use test_support::ninja_edge::edge_has_console_pool; +/// +/// let ninja = "build lint: command\n pool = console\n"; +/// assert!(edge_has_console_pool(ninja, "lint")?); +/// # Ok::<(), anyhow::Error>(()) +/// ``` +/// +/// # Errors +/// +/// Returns an error when the named build edge is absent. +pub fn edge_has_console_pool(ninja: &str, output: &str) -> Result { + let lines: Vec<_> = ninja.lines().collect(); + let prefix = format!("build {output}:"); + let edge_index = lines + .iter() + .position(|line| line.starts_with(&prefix)) + .with_context(|| format!("missing Ninja build edge for {output}"))?; + + Ok(lines + .iter() + .skip(edge_index + 1) + .take_while(|line| line.starts_with(" ")) + .any(|line| *line == " pool = console")) +} diff --git a/tests/ast_tests/console.rs b/tests/ast_tests/console.rs index bcdce662b..72d8f7918 100644 --- a/tests/ast_tests/console.rs +++ b/tests/ast_tests/console.rs @@ -60,7 +60,7 @@ fn dependency_only_console_manifest_error_is_localized() -> Result<()> { /// Keep contention fields unknown until RFC 0024 defines a manifest schema. #[test] -fn contention_remains_an_unknown_manifest_field() { +fn contention_remains_an_unknown_manifest_field() -> Result<()> { let yaml = r#" netsuke_version: "1.0.0" targets: @@ -69,8 +69,11 @@ fn contention_remains_an_unknown_manifest_field() { console: true contention: developer-gates "#; - assert!( - parse_manifest(yaml).is_err(), - "contention should remain an unknown manifest field" + let error = parse_manifest(yaml).expect_err("contention must be rejected"); + let rendered = format!("{error:#}"); + ensure!( + rendered.contains("unknown field `contention`"), + "contention should fail as an unknown manifest field: {rendered}" ); + Ok(()) } diff --git a/tests/ast_tests/dependency_only.rs b/tests/ast_tests/dependency_only.rs index 81f057546..c08ede56b 100644 --- a/tests/ast_tests/dependency_only.rs +++ b/tests/ast_tests/dependency_only.rs @@ -231,6 +231,7 @@ fn direct_ast_deserialisation_is_validated_before_ir_lowering() -> Result<()> { #[case::action("actions:\n - name: all\n deps: [lint]\n console: true\ntargets: []\n")] #[case::target("actions: []\ntargets:\n - name: release\n deps: [build]\n console: true\n")] fn dependency_only_console_entries_are_rejected(#[case] sections: &str) -> Result<()> { + let _locale = locale_localizer("en-US"); let yaml = format!("netsuke_version: '1.0.0'\n{sections}"); let error = parse_manifest(&yaml).expect_err("dependency-only entries cannot use the console pool"); diff --git a/tests/bdd/steps/ninja.rs b/tests/bdd/steps/ninja.rs index a8d23baf0..65d4b8035 100644 --- a/tests/bdd/steps/ninja.rs +++ b/tests/bdd/steps/ninja.rs @@ -207,18 +207,9 @@ fn ninja_contains(world: &TestWorld, fragment: &str) -> Result<()> { #[then("the Ninja edge {output:string} has a console pool binding")] fn ninja_edge_has_console_pool_binding(world: &TestWorld, output: &str) -> Result<()> { let ninja = get_ninja_content(world)?; - let lines: Vec<_> = ninja.lines().collect(); - let prefix = format!("build {output}:"); - let edge_index = lines - .iter() - .position(|line| line.starts_with(&prefix)) - .with_context(|| format!("missing Ninja build edge for {output}"))?; + let has_console_pool = test_support::ninja_edge::edge_has_console_pool(&ninja, output)?; ensure!( - lines - .iter() - .skip(edge_index + 1) - .take_while(|line| line.starts_with(" ")) - .any(|line| *line == " pool = console"), + has_console_pool, "Ninja edge {output} has no console pool binding:\n{ninja}" ); Ok(()) diff --git a/tests/console_pool_runtime_tests.rs b/tests/console_pool_runtime_tests.rs index 0e5471b5e..60c6527bd 100644 --- a/tests/console_pool_runtime_tests.rs +++ b/tests/console_pool_runtime_tests.rs @@ -19,7 +19,7 @@ use std::{ time::{Duration, Instant}, }; use tempfile::TempDir; -use test_support::ninja::ninja_integration_workspace; +use test_support::ninja::{NinjaWorkspaceError, ninja_integration_workspace}; const PROCESS_DEADLINE: Duration = Duration::from_secs(60); const POLL_INTERVAL: Duration = Duration::from_millis(10); @@ -211,21 +211,30 @@ fn finish_successfully(cleanup: &mut ChildCleanup) -> Result<()> { Ok(()) } -/// Probe Ninja or skip this optional local runtime test when it is unavailable. -fn runtime_workspace() -> Option { +/// Probe Ninja and skip this optional local runtime test when it cannot run. +/// +/// # Errors +/// +/// Returns a workspace-creation error instead of silently skipping the test. +fn runtime_workspace() -> Result, NinjaWorkspaceError> { match ninja_integration_workspace() { - Ok(workspace) => Some(workspace), - Err(error) => { + Ok(workspace) => Ok(Some(workspace)), + Err( + error @ (NinjaWorkspaceError::ProbeSpawn(_) + | NinjaWorkspaceError::ProbeFailed(_) + | NinjaWorkspaceError::ProbeTimeout(_)), + ) => { tracing::warn!(%error, "skipping console runtime test without Ninja"); - None + Ok(None) } + Err(error) => Err(error), } } /// Forward a flushed console-pool marker before the blocked command completes. #[test] fn console_output_reaches_piped_stdout_before_the_recipe_exits() -> Result<()> { - let Some(workspace) = runtime_workspace() else { + let Some(workspace) = runtime_workspace()? else { return Ok(()); }; let root = workspace_dir(&workspace)?; diff --git a/tests/console_pool_runtime_tests/exclusivity.rs b/tests/console_pool_runtime_tests/exclusivity.rs index 257b62494..e37161cb0 100644 --- a/tests/console_pool_runtime_tests/exclusivity.rs +++ b/tests/console_pool_runtime_tests/exclusivity.rs @@ -11,7 +11,7 @@ use cap_std::fs_utf8::Dir; /// Keep two console recipes from overlapping under a parallel Ninja job limit. #[test] fn console_edges_are_exclusive_with_parallel_jobs_enabled() -> Result<()> { - let Some(workspace) = runtime_workspace() else { + let Some(workspace) = runtime_workspace()? else { return Ok(()); }; let root = workspace_dir(&workspace)?; diff --git a/tests/documentation_examples_tests.rs b/tests/documentation_examples_tests.rs index 6e91c11ab..a5ed0c876 100644 --- a/tests/documentation_examples_tests.rs +++ b/tests/documentation_examples_tests.rs @@ -261,13 +261,10 @@ fn documented_console_example_selects_the_console_pool() -> Result<()> { let run = run_netsuke_in(workspace.path(), &["--progress", "never", "generate"])?; assert_success(&run, "guide console-pool target")?; - let lint_edge = run - .stdout - .split_once("build lint:") - .map(|(_, following)| following.split("\nbuild ").next().unwrap_or(following)) + let has_console_pool = test_support::ninja_edge::edge_has_console_pool(&run.stdout, "lint") .context("generated Ninja should contain the lint build edge")?; ensure!( - lint_edge.contains(" pool = console"), + has_console_pool, "the documented lint target should select the console pool: {}", run.stdout ); diff --git a/tests/ninja_snapshot_tests.rs b/tests/ninja_snapshot_tests.rs index 095534a2f..bc764e2c4 100644 --- a/tests/ninja_snapshot_tests.rs +++ b/tests/ninja_snapshot_tests.rs @@ -290,7 +290,11 @@ fn manifest_ninja_snapshot( ); } for (output, expected) in expected_console_bindings { - assert_console_edge_binding(&ninja_content, output, *expected)?; + let actual = test_support::ninja_edge::edge_has_console_pool(&ninja_content, output)?; + ensure!( + actual == *expected, + "edge {output} console binding should be {expected}, generated Ninja:\n{ninja_content}" + ); } let mut settings = Settings::new(); settings.set_snapshot_path(concat!( @@ -304,30 +308,6 @@ fn manifest_ninja_snapshot( Ok(()) } -/// Assert whether one named edge has a console binding. -/// -/// # Errors -/// -/// Returns an error when the edge is missing or its binding does not match. -fn assert_console_edge_binding(ninja: &str, output: &str, expected: bool) -> Result<()> { - let lines: Vec<_> = ninja.lines().collect(); - let prefix = format!("build {output}:"); - let edge_index = lines - .iter() - .position(|line| line.starts_with(&prefix)) - .with_context(|| format!("missing Ninja build edge for {output}"))?; - let actual = lines - .iter() - .skip(edge_index + 1) - .take_while(|line| line.starts_with(" ")) - .any(|line| *line == " pool = console"); - ensure!( - actual == expected, - "edge {output} console binding should be {expected}, generated Ninja:\n{ninja}" - ); - Ok(()) -} - /// Snapshot dependency-only actions as native Ninja phony nodes. #[test] fn dependency_only_manifest_ninja_snapshot() -> Result<()> { From 251e6520103bd654cc40f4685df32604a83cd691 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 27 Oct 2026 04:54:28 +0100 Subject: [PATCH 06/27] Address console pool review findings Correct telemetry accounting when Ninja process options fail before launch, and add a regression test. Match generated build outputs across multiple outputs and escaped spaces. Apply review corrections to the related documentation and localized diagnostics. --- docs/developers-guide.md | 6 +- docs/netsuke-design.md | 4 +- .../rfcs/rfc-0024-named-contention-classes.md | 4 +- docs/v0-1-0-migration-guide.md | 54 ++++++------- locales/cy/messages.ftl | 4 +- locales/el/messages.ftl | 4 +- locales/es-419/messages.ftl | 2 +- locales/es-ES/messages.ftl | 2 +- locales/id/messages.ftl | 4 +- locales/ko/messages.ftl | 4 +- locales/nb/messages.ftl | 2 +- src/runner/tests/mod.rs | 1 + .../tests/ninja_build_telemetry_tests.rs | 39 ++++++++++ test_support/src/ninja_edge.rs | 78 ++++++++++++++++++- 14 files changed, 163 insertions(+), 45 deletions(-) create mode 100644 src/runner/tests/ninja_build_telemetry_tests.rs diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 372637554..9f39def68 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -6515,6 +6515,10 @@ custom action pools that conflict with `console`; it accepts an existing `Action.pool` value of `console`. Synthetic serial gates and dyndep sidecars never inherit either binding. +The emitted `pool = console` binding requires Ninja 1.5 or newer. This minimum +is separate from the conditional Ninja 1.10 requirement for staged serial +dependency bundles described below. + AST validation keeps stable diagnostic details separate from localized display text. `src/ir/graph/error.rs::manifest_recipe_validation_message` maps a recognized manifest detail to the same user-facing message for parsing and @@ -7335,7 +7339,7 @@ borrowed view of generated text. `test_support::ninja_edge::edge_has_console_pool` (`test_support/src/ninja_edge.rs`) checks edge-local pool metadata on a named generated `build` statement. Snapshot, BDD, and documentation-example tests use -this shared helper so their assertions agree about which edge owns an indented +this shared helper, so their assertions agree about which edge owns an indented `pool = console` binding. Its scope is deliberately narrow: it inspects the generated text used by these tests and does not parse general Ninja syntax. Permitted call sites are test-support and test code only; production code must diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index f97f816aa..cdaf797d1 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -2879,8 +2879,8 @@ The live IR structures defined in [src/ir/mod.rs](../src/ir/mod.rs), are minimal containers that mirror Ninja's conceptual model while keeping most scheduling data backend-agnostic. `BuildGraph` collects actions in a hash map, canonical edges in an -insertion-ordered arena, and output aliases in a `Utf8PathBuf` to `EdgeId` hash -map. Actions hold the parsed `Recipe` and optional execution metadata. +insertion-ordered arena, and a map from `Utf8PathBuf` to `EdgeId` for output +aliases. Actions hold the parsed `Recipe` and optional execution metadata. `BuildEdge` connects inputs to outputs using an action identifier and carries the `phony`, `always`, and `console` flags verbatim from the manifest. The `console` flag is a deliberate Ninja-specific edge annotation; the IR stores a diff --git a/docs/rfcs/rfc-0024-named-contention-classes.md b/docs/rfcs/rfc-0024-named-contention-classes.md index f0e36cdc5..f754e851d 100644 --- a/docs/rfcs/rfc-0024-named-contention-classes.md +++ b/docs/rfcs/rfc-0024-named-contention-classes.md @@ -109,8 +109,8 @@ combining their names into a new class would not enforce the original limits. Ninja's special `console` pool cannot simultaneously be combined with a custom pool on the same edge. When a manifest edge opts into `console`, generation rejects an existing custom `Action.pool` value other than `console`; an existing -`Action.pool: console` is redundant and accepted. A console edge with a -resolved contention class is rejected as well. Console selection remains +`Action.pool` setting to `console` is redundant and accepted. A console edge +with a resolved contention class is rejected as well. Console selection remains available without contention, and neither behaviour is silently discarded. Serialize resolved class definitions and references in generated plans and diff --git a/docs/v0-1-0-migration-guide.md b/docs/v0-1-0-migration-guide.md index 1ba2ad200..d07ab6523 100644 --- a/docs/v0-1-0-migration-guide.md +++ b/docs/v0-1-0-migration-guide.md @@ -9,14 +9,14 @@ sections that match the features they use. ## Impact at a glance -| Priority | Area | What to do | -| ------------------ | ---------------------- | ------------------------------------------------------------------- | -| Required | Checkout source builds | Use the pinned Rust toolchain before building from a checkout. | -| Required | Linux checkout builds | Install the pinned `mold` linker before building from a checkout. | -| Review if enabled | Manifest scheduling | Opt in only where immediate console output is useful. | -| Required if called | Rust `Target` literals | Set the `description` and `console` fields explicitly. | -| Review if enabled | Existing pool choices | Avoid dependency-only entries and conflicting action pools. | -| Optional | Existing manifests | Omit `console` to retain ordinary scheduling; it defaults to false. | +| Priority | Area | What to do | +| ----------------- | ---------------------- | ------------------------------------------------------------------- | +| Required | Checkout source builds | Use the pinned Rust toolchain before building from a checkout. | +| Required | Linux checkout builds | Install the pinned `mold` linker before building from a checkout. | +| Review if enabled | Manifest scheduling | Opt in only where immediate console output is useful. | +| Required | Rust `Target` literals | Set the `description` and `console` fields explicitly. | +| Review if enabled | Existing pool choices | Avoid dependency-only entries and conflicting action pools. | +| Optional | Existing manifests | Omit `console` to retain ordinary scheduling; it defaults to false. | _Table 1: migration obligations for the beta series and console-pool option._ @@ -660,25 +660,25 @@ for the full policy and its diagnostics. Table: documented v0.1.0 additions, including `netsuke help targets`, and their impact -| Area | Impact | Where to read more | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| Convenience wrappers | Unchanged. `run_ninja` and `run_ninja_tool` behave exactly as before, inheriting the process environment. | [Users' guide](users-guide.md) | -| Child environment | New opt-in `netsuke::runner::CommandEnv` carries additive variable overrides and an injected `PATH` for Ninja child processes. | [Developers' guide](developers-guide.md#ninja-invocation-with-an-explicit-environment) | -| Request types | New `netsuke::runner::NinjaBuildRequest` and `netsuke::runner::NinjaToolRequest` name the program, `NinjaProcessOptions`, build file, targets or tool, a child environment, and a required `stderr_mode: StderrMode` policy for the `*_with` run functions. | [Developers' guide](developers-guide.md#ninja-invocation-with-an-explicit-environment) | -| Cached CLI configuration API | Breaking for callers of the unstable Rust API: use the opt-in cached discovery flow with `ConfigEnvProvider`; `ConfigStdEnvProvider` supplies process-backed access. | [Developers' guide](developers-guide.md#cached-configuration-merge) | -| Timing output | Existing `VerboseTimingReporter::new` keeps its stderr sink; Rust callers can opt into an owned `Write + Send` sink with `with_writer`. | [Developers' guide](developers-guide.md#verbose-timing-sink) | -| Glob expansion | Parent-relative patterns such as `glob('../shared/*.h')` now expand. The Jinja helper rejects matched paths that are not portable unquoted shell words. Metadata checks use a capability rooted at the pattern's longest literal directory prefix; missing or non-directory prefixes return no matches, and unresolvable symlink matches are skipped. | [Users' guide](users-guide.md) and [ADR-0010](adrs/adr-0010-scope-the-glob-metadata-capability-to-the-literal-prefix.md) | -| Executable discovery | **Breaking:** default `which` and `command_available` no longer recursively discover workspace executables when `PATH` is empty or unset. Opt in with `cwd_mode='workspace-recursive'` only for trusted workspaces. | [Users' guide](users-guide.md#template-helpers) | -| Command recipes | On Windows, legacy scalar commands, lists, and scripts use Windows PowerShell by default; YAML command lists remain opt-in, ordered, and fail-fast. | [Windows legacy recipe contract](users-guide.md#windows-legacy-recipe-contract) | -| Ninja text escaping | Write shell dollars normally. In `command:` and `script:` recipes, `$in`, `$out`, `$ins`, and `$outs` are shell variables, so Ninja emits their dollars doubled and the selected shell receives their ordinary single-dollar spelling. `{{ ins }}` and `{{ outs }}` are the only Netsuke path markers. A manifest that used script `$in` or `$out` for Netsuke path substitution must use `{{ ins }}` or `{{ outs }}` instead. Spaces in build and default-target paths are escaped for Ninja as a `$` followed by a space, so whitespace-containing outputs remain valid. Paths containing `\|` or control characters remain rejected, as do paths containing `$` or colon, which Ninja can escape but Netsuke does not yet accept. Emitted metadata is escaped separately: a literal dollar in a description, `depfile`, `deps`, or `pool` is doubled, so `depfile: $out.d` is written as `$$out.d` and Ninja reads the literal name. Newline, carriage-return, and NUL metadata values are rejected. | [Users' guide](users-guide.md#review-the-safety-boundary) | -| Manifest discovery | Optional target/action `description` values are shown by the new `netsuke help targets` command. Manifests without them and existing build output are unchanged. | [Users' guide](users-guide.md) | -| Serial dependencies | New opt-in `dependency_order: serial` runs an action or target's direct `deps` list in declaration order. | [Serial dependency ordering](users-guide.md#run-direct-dependencies-serially) | -| Graph export | New opt-in `netsuke --json help targets --edges` output exposes a versioned graph beside the existing catalogue; root-level `--json` must precede `help`; omitting `--edges` preserves current output, and manifests need no changes. | [Graph export reference](users-guide.md#export-dependency-edges-and-raw-recipes) | -| Fetch redirects | Every redirect destination is now evaluated against the network policy before it is requested, so a redirect can no longer reach a host, scheme, or address the policy refuses. Chains stop after five redirects, a repeated destination is refused as a loop, and URL credentials are removed when the origin changes. | [Users' guide](users-guide.md#network-fetch-policy) and [ADR-0023](adrs/adr-0023-revalidate-every-fetch-redirect.md) | -| Manifest environment access | New optional exact-name `env()` allow and block lists. Existing manifests retain default-allow behaviour when neither list is configured; an active allowlist enables default-deny and a block always wins. | [Users' guide](users-guide.md#control-manifest-environment-access) | -| File-reading filters | The `contents`, `linecount`, `hash`, and `digest` filters now read under one 8 MiB default byte budget; a symlink final component is rejected unless `follow_symlinks=true` opts in, while FIFOs and devices are rejected outright, and per-call `max_bytes` can only narrow the budget. | [Configure file reading limits](users-guide.md#configure-file-reading-limits) | -| Script shell variables | **Breaking:** `$in` and `$out` in `script:` recipes are shell variables, as they already were in `command:` recipes. Use `{{ ins }}` and `{{ outs }}` for Netsuke path substitution. | [Script shell variables](#replace-script-in-and-out-path-substitution) | -| Build graph | **Breaking for callers of the unstable Rust API:** each logical build edge is stored once and indexed by output through `EdgeId`; `BuildGraph::targets` is no longer public. | [Graph accessors](#resolve-build-graph-outputs-through-the-accessors) | +| Area | Impact | Where to read more | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | +| Convenience wrappers | Unchanged. `run_ninja` and `run_ninja_tool` behave exactly as before, inheriting the process environment. | [Users' guide](users-guide.md) | +| Child environment | New opt-in `netsuke::runner::CommandEnv` carries additive variable overrides and an injected `PATH` for Ninja child processes. | [Developers' guide](developers-guide.md#ninja-invocation-with-an-explicit-environment) | +| Request types | New `netsuke::runner::NinjaBuildRequest` and `netsuke::runner::NinjaToolRequest` name the program, `NinjaProcessOptions`, build file, targets or tool, a child environment, and a required `stderr_mode: StderrMode` policy for the `*_with` run functions. | [Developers' guide](developers-guide.md#ninja-invocation-with-an-explicit-environment) | +| Cached CLI configuration API | Breaking for callers of the unstable Rust API: use the opt-in cached discovery flow with `ConfigEnvProvider`; `ConfigStdEnvProvider` supplies process-backed access. | [Developers' guide](developers-guide.md#cached-configuration-merge) | +| Timing output | Existing `VerboseTimingReporter::new` keeps its stderr sink; Rust callers can opt into an owned `Write + Send` sink with `with_writer`. | [Developers' guide](developers-guide.md#verbose-timing-sink) | +| Glob expansion | Parent-relative patterns such as `glob('../shared/*.h')` now expand. The Jinja helper rejects matched paths that are not portable unquoted shell words. Metadata checks use a capability rooted at the pattern's longest literal directory prefix; missing or non-directory prefixes return no matches, and unresolvable symlink matches are skipped. | [Users' guide](users-guide.md) and [ADR-0010](adrs/adr-0010-scope-the-glob-metadata-capability-to-the-literal-prefix.md) | +| Executable discovery | **Breaking:** default `which` and `command_available` no longer recursively discover workspace executables when `PATH` is empty or unset. Opt in with `cwd_mode='workspace-recursive'` only for trusted workspaces. | [Users' guide](users-guide.md#template-helpers) | +| Command recipes | On Windows, legacy scalar commands, lists, and scripts use Windows PowerShell by default; YAML command lists remain opt-in, ordered, and fail-fast. | [Windows legacy recipe contract](users-guide.md#windows-legacy-recipe-contract) | +| Ninja text escaping | Write shell dollars normally. In `command:` and `script:` recipes, `$in`, `$out`, `$ins`, and `$outs` are shell variables, so Ninja emits their dollars doubled, and the selected shell receives their ordinary single-dollar spelling. `{{ ins }}` and `{{ outs }}` are the only Netsuke path markers. A manifest that used script `$in` or `$out` for Netsuke path substitution must use `{{ ins }}` or `{{ outs }}` instead. Spaces in build and default-target paths are escaped for Ninja as a `$` followed by a space, so whitespace-containing outputs remain valid. Paths containing `\|` or control characters remain rejected. Ninja can escape paths containing `$` or colon, but Netsuke does not yet accept them. Emitted metadata is escaped separately: a literal dollar in a description, `depfile`, `deps`, or `pool` is doubled, so `depfile: $out.d` is written as `$$out.d` and Ninja reads the literal name. Newline, carriage-return, and NUL metadata values are rejected. | [Users' guide](users-guide.md#review-the-safety-boundary) | +| Manifest discovery | Optional target/action `description` values are shown by the new `netsuke help targets` command. Manifests without them and existing build output are unchanged. | [Users' guide](users-guide.md) | +| Serial dependencies | New opt-in `dependency_order: serial` runs an action or target's direct `deps` list in declaration order. | [Serial dependency ordering](users-guide.md#run-direct-dependencies-serially) | +| Graph export | New opt-in `netsuke --json help targets --edges` output exposes a versioned graph beside the existing catalogue; root-level `--json` must precede `help`; omitting `--edges` preserves current output, and manifests need no changes. | [Graph export reference](users-guide.md#export-dependency-edges-and-raw-recipes) | +| Fetch redirects | Every redirect destination is now evaluated against the network policy before it is requested, so a redirect can no longer reach a host, scheme, or address the policy refuses. Chains stop after five redirects, a repeated destination is refused as a loop, and URL credentials are removed when the origin changes. | [Users' guide](users-guide.md#network-fetch-policy) and [ADR-0023](adrs/adr-0023-revalidate-every-fetch-redirect.md) | +| Manifest environment access | New optional exact-name `env()` allow and block lists. Existing manifests retain default-allow behaviour when neither list is configured; an active allowlist enables default-deny and a block always wins. | [Users' guide](users-guide.md#control-manifest-environment-access) | +| File-reading filters | The `contents`, `linecount`, `hash`, and `digest` filters now read under one 8 MiB default byte budget; a symlink final component is rejected unless `follow_symlinks=true` opts in, while FIFOs and devices are rejected outright, and per-call `max_bytes` can only narrow the budget. | [Configure file reading limits](users-guide.md#configure-file-reading-limits) | +| Script shell variables | **Breaking:** `$in` and `$out` in `script:` recipes are shell variables, as they already were in `command:` recipes. Use `{{ ins }}` and `{{ outs }}` for Netsuke path substitution. | [Script shell variables](#replace-script-in-and-out-path-substitution) | +| Build graph | **Breaking for callers of the unstable Rust API:** each logical build edge is stored once and indexed by output through `EdgeId`; `BuildGraph::targets` is no longer public. | [Graph accessors](#resolve-build-graph-outputs-through-the-accessors) | ## What does not need to change diff --git a/locales/cy/messages.ftl b/locales/cy/messages.ftl index 137ef9ab4..c5ddd4797 100644 --- a/locales/cy/messages.ftl +++ b/locales/cy/messages.ftl @@ -199,8 +199,8 @@ ir.invalid_command = Mewnosodiad annilys yn y gorchymyn: { $snippet }. # Gwallau cynhyrchu Ninja. ninja_gen.missing_action = Mae'r weithred ‘{ $id }’ y cyfeirir ati gan ymyl adeiladu ar goll. -ninja_gen.console_dependency_only = Ni all ymyl adeiladu ‘{ $output }’ ddewis y gronfa consol am mai dibyniaethau yn unig yw ei weithred. -ninja_gen.console_pool_conflict = Mae ymyl adeiladu ‘{ $output }’ yn dewis y gronfa consol, ond mae ei weithred yn gofyn am gronfa addasedig ‘{ $pool }’. +ninja_gen.console_dependency_only = Ni all ymyl adeiladu ‘{ $output }’ ddewis pwll y consol am mai dibyniaethau yn unig yw ei weithred. +ninja_gen.console_pool_conflict = Mae ymyl adeiladu ‘{ $output }’ yn dewis pwll y consol, ond mae ei weithred yn gofyn am gronfa addasedig ‘{ $pool }’. ninja_gen.format = Methwyd â fformatio allbwn y maniffest Ninja. ninja_gen.dyndep_files_required = Mae'r adeilad hwn yn gofyn am fwndel Ninja a gynhyrchwyd; defnyddiwch `netsuke build`, `netsuke clean` neu `netsuke generate` er mwyn deunyddoli ffeiliau dyndep. ninja_gen.reserved_output_path = Mae'r llwybr '{ $path }' wedi'i gadw ar gyfer cyflwr dibyniaethau cyfresol Netsuke. diff --git a/locales/el/messages.ftl b/locales/el/messages.ftl index 20a7df1e5..f47c2c76a 100644 --- a/locales/el/messages.ftl +++ b/locales/el/messages.ftl @@ -200,8 +200,8 @@ ir.invalid_command = Μη έγκυρη παρεμβολή στην εντολή: # Σφάλματα παραγωγής αρχείων Ninja. ninja_gen.missing_action = Λείπει η ενέργεια «{ $id }» στην οποία παραπέμπει ακμή δόμησης. -ninja_gen.console_dependency_only = Η ακμή δόμησης «{ $output }» δεν μπορεί να επιλέξει την ομάδα κονσόλας, επειδή η ενέργειά της περιλαμβάνει μόνο εξαρτήσεις. -ninja_gen.console_pool_conflict = Η ακμή δόμησης «{ $output }» επιλέγει την ομάδα κονσόλας, αλλά η ενέργειά της ζητά την προσαρμοσμένη ομάδα «{ $pool }». +ninja_gen.console_dependency_only = Η ακμή δόμησης «{ $output }» δεν μπορεί να επιλέξει το pool της κονσόλας, επειδή η ενέργειά της περιλαμβάνει μόνο εξαρτήσεις. +ninja_gen.console_pool_conflict = Η ακμή δόμησης «{ $output }» επιλέγει το pool της κονσόλας, αλλά η ενέργειά της ζητά το προσαρμοσμένο pool «{ $pool }». ninja_gen.format = Δεν ήταν δυνατή η μορφοποίηση της εξόδου του δηλωτικού Ninja. ninja_gen.dyndep_files_required = Αυτή η δόμηση απαιτεί παραγόμενο πακέτο Ninja· χρησιμοποιήστε `netsuke build`, `netsuke clean` ή `netsuke generate`, ώστε να δημιουργηθούν τα αρχεία dyndep. ninja_gen.reserved_output_path = Η διαδρομή '{ $path }' είναι δεσμευμένη για την κατάσταση σειριακών εξαρτήσεων του Netsuke. diff --git a/locales/es-419/messages.ftl b/locales/es-419/messages.ftl index 0c221c06a..448d9694f 100644 --- a/locales/es-419/messages.ftl +++ b/locales/es-419/messages.ftl @@ -187,7 +187,7 @@ manifest.glob.unknown_io_error = error de E/S desconocido. manifest.command_list_empty = El campo 'command' no debe estar vacío: proporcione una cadena de comando o una lista no vacía. manifest.command_isolation_recipe = El campo 'command_isolation' requiere una receta con una lista de comandos. manifest.command_isolation_scalar = 'command_isolation: isolated' requiere una lista de comandos. -manifest.console_dependency_only = Las entradas que solo declaran dependencias no pueden seleccionar el grupo de la consola. +manifest.console_dependency_only = Las entradas que solo declaran dependencias no pueden seleccionar el grupo de consola. # Errores de la representación intermedia. ir.rule_not_found = No se encontró la regla '{ $rule }' referenciada por el objetivo '{ $target }'. diff --git a/locales/es-ES/messages.ftl b/locales/es-ES/messages.ftl index 5b0b8b17b..9c13d88a2 100644 --- a/locales/es-ES/messages.ftl +++ b/locales/es-ES/messages.ftl @@ -186,7 +186,7 @@ manifest.glob.unknown_io_error = error de E/S desconocido. manifest.command_list_empty = El campo 'command' no debe estar vacío: proporcione una cadena de comando o una lista no vacía. manifest.command_isolation_recipe = El campo 'command_isolation' requiere una receta con una lista de comandos. manifest.command_isolation_scalar = 'command_isolation: isolated' requiere una lista de comandos. -manifest.console_dependency_only = Las entradas que solo declaran dependencias no pueden seleccionar el grupo de la consola. +manifest.console_dependency_only = Las entradas que solo declaran dependencias no pueden seleccionar el grupo de consola. # Errores de IR. ir.rule_not_found = No se encontró la regla '{ $rule }' referenciada por el objetivo '{ $target }'. diff --git a/locales/id/messages.ftl b/locales/id/messages.ftl index ffd66a13c..466bc2a47 100644 --- a/locales/id/messages.ftl +++ b/locales/id/messages.ftl @@ -199,8 +199,8 @@ ir.invalid_command = Penyisipan tidak sah pada perintah: { $snippet }. # Galat pembuatan berkas Ninja. ninja_gen.missing_action = Tindakan "{ $id }" yang dirujuk sebuah sisi build tidak ada. -ninja_gen.console_dependency_only = Edge build ‘{ $output }’ tidak dapat memilih pool konsol karena aksinya hanya berisi dependensi. -ninja_gen.console_pool_conflict = Edge build ‘{ $output }’ memilih pool konsol, tetapi aksinya meminta pool khusus ‘{ $pool }’. +ninja_gen.console_dependency_only = Sisi build ‘{ $output }’ tidak dapat memilih pool konsol karena aksinya hanya berisi dependensi. +ninja_gen.console_pool_conflict = Sisi build ‘{ $output }’ memilih pool konsol, tetapi aksinya meminta pool khusus ‘{ $pool }’. ninja_gen.format = Keluaran manifes Ninja tidak dapat diformat. ninja_gen.dyndep_files_required = Build ini memerlukan bundel Ninja yang dibuat; gunakan `netsuke build`, `netsuke clean`, atau `netsuke generate` agar berkas dyndep diwujudkan. ninja_gen.reserved_output_path = Jalur '{ $path }' dicadangkan untuk status dependensi serial Netsuke. diff --git a/locales/ko/messages.ftl b/locales/ko/messages.ftl index a58379f86..264940876 100644 --- a/locales/ko/messages.ftl +++ b/locales/ko/messages.ftl @@ -199,8 +199,8 @@ ir.invalid_command = 명령의 보간이 잘못되었습니다: { $snippet }. # Ninja 생성 오류. ninja_gen.missing_action = 빌드 간선이 참조하는 동작 '{ $id }'이(가) 없습니다. -ninja_gen.console_dependency_only = 빌드 에지 ‘{ $output }’는 콘솔 풀을 선택할 수 없습니다. 작업이 종속성만으로 이루어져 있기 때문입니다. -ninja_gen.console_pool_conflict = 빌드 에지 ‘{ $output }’는 콘솔 풀을 선택하지만 작업은 사용자 지정 풀 ‘{ $pool }’을 요청합니다. +ninja_gen.console_dependency_only = 빌드 간선 ‘{ $output }’는 콘솔 풀을 선택할 수 없습니다. 작업이 종속성만으로 이루어져 있기 때문입니다. +ninja_gen.console_pool_conflict = 빌드 간선 ‘{ $output }’는 콘솔 풀을 선택하지만 작업은 사용자 지정 풀 ‘{ $pool }’을 요청합니다. ninja_gen.format = Ninja 매니페스트 출력의 서식을 지정하지 못했습니다. ninja_gen.dyndep_files_required = 이 작업에는 생성된 Ninja 번들이 필요합니다. dyndep 파일을 생성하려면 `netsuke build`, `netsuke clean` 또는 `netsuke generate`를 사용하세요. ninja_gen.reserved_output_path = 경로 '{ $path }'은(는) Netsuke의 직렬 종속성 상태용으로 예약되어 있습니다. diff --git a/locales/nb/messages.ftl b/locales/nb/messages.ftl index 61dd00b34..4fd8d196a 100644 --- a/locales/nb/messages.ftl +++ b/locales/nb/messages.ftl @@ -186,7 +186,7 @@ manifest.glob.unknown_io_error = ukjent I/U-feil. manifest.command_list_empty = Feltet «command» kan ikke være tomt: oppgi en kommandostreng eller en ikke-tom liste. manifest.command_isolation_recipe = Feltet ‘command_isolation’ krever en oppskrift med en kommandoliste. manifest.command_isolation_scalar = ‘command_isolation: isolated’ krever en kommandoliste. -manifest.console_dependency_only = Oppføringer som bare angir avhengigheter, kan ikke velge konsollpuljen. +manifest.console_dependency_only = Oppføringer som bare angir avhengigheter kan ikke velge konsollpuljen. # Feil i den interne representasjonen. ir.rule_not_found = Regelen «{ $rule }» som målet «{ $target }» viser til, ble ikke funnet. diff --git a/src/runner/tests/mod.rs b/src/runner/tests/mod.rs index fb0b4613e..82084b4eb 100644 --- a/src/runner/tests/mod.rs +++ b/src/runner/tests/mod.rs @@ -22,6 +22,7 @@ use tracing_subscriber::filter::LevelFilter; #[cfg(feature = "lint")] mod check_dispatch_tests; mod manifest_structure_telemetry_tests; +mod ninja_build_telemetry_tests; #[path = "shell_seam_tests.rs"] mod shell_seam_tests; diff --git a/src/runner/tests/ninja_build_telemetry_tests.rs b/src/runner/tests/ninja_build_telemetry_tests.rs new file mode 100644 index 000000000..9b7eb2ed8 --- /dev/null +++ b/src/runner/tests/ninja_build_telemetry_tests.rs @@ -0,0 +1,39 @@ +//! Check telemetry boundaries around runner-owned Ninja builds. + +use super::*; +use crate::runner::{NINJA_BUILD_EXECUTIONS_TOTAL, run_with_ninja_program}; +use anyhow::{Result, ensure}; +use metrics_util::debugging::DebuggingRecorder; +use test_support::{localizer_test_lock, set_en_localizer}; + +#[test] +fn invalid_progress_options_do_not_record_a_ninja_build() -> Result<()> { + let _lock = localizer_test_lock().map_err(|error| anyhow::anyhow!("{error}"))?; + let _guard = set_en_localizer(); + let (_temp, manifest_path) = write_manifest(MINIMAL_MANIFEST)?; + let cli = Cli { + file: manifest_path, + jobs: Some(0), + ..Cli::default() + }; + let program = camino::Utf8Path::new("ninja"); + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + + let (result, metrics) = metrics::with_local_recorder(&recorder, || { + let result = run_with_ninja_program(&cli, crate::output_prefs::resolve(None), program); + (result, snapshotter.snapshot().into_vec()) + }); + + ensure!( + result.is_err(), + "an invalid job count must fail before Ninja runs" + ); + ensure!( + !metrics + .iter() + .any(|(key, _, _, _)| key.key().name() == NINJA_BUILD_EXECUTIONS_TOTAL), + "option validation must not record a Ninja build: {metrics:?}" + ); + Ok(()) +} diff --git a/test_support/src/ninja_edge.rs b/test_support/src/ninja_edge.rs index 9c56eae47..6b70de56f 100644 --- a/test_support/src/ninja_edge.rs +++ b/test_support/src/ninja_edge.rs @@ -27,10 +27,9 @@ use anyhow::{Context, Result}; /// Returns an error when the named build edge is absent. pub fn edge_has_console_pool(ninja: &str, output: &str) -> Result { let lines: Vec<_> = ninja.lines().collect(); - let prefix = format!("build {output}:"); let edge_index = lines .iter() - .position(|line| line.starts_with(&prefix)) + .position(|line| build_edge_has_output(line, output)) .with_context(|| format!("missing Ninja build edge for {output}"))?; Ok(lines @@ -39,3 +38,78 @@ pub fn edge_has_console_pool(ninja: &str, output: &str) -> Result { .take_while(|line| line.starts_with(" ")) .any(|line| *line == " pool = console")) } + +/// Return whether a generated build statement names `output` in its output list. +fn build_edge_has_output(line: &str, output: &str) -> bool { + let Some(outputs) = line + .strip_prefix("build ") + .and_then(|statement| statement.split_once(": ").map(|(outputs, _)| outputs)) + else { + return false; + }; + + let ninja_output = output.replace(' ', "$ "); + ninja_output_tokens(outputs) + .iter() + .any(|candidate| !candidate.is_empty() && candidate == &ninja_output) +} + +/// Split generated Ninja outputs while retaining escaped spaces within a path. +fn ninja_output_tokens(outputs: &str) -> Vec { + let mut tokens = Vec::new(); + let mut token = String::new(); + let mut is_escaped = false; + + for character in outputs.chars() { + match character { + '$' if !is_escaped => { + token.push('$'); + is_escaped = true; + } + escaped if is_escaped => { + token.push(escaped); + is_escaped = false; + } + delimiter if delimiter.is_whitespace() || delimiter == '|' => { + tokens.push(std::mem::take(&mut token)); + } + other => token.push(other), + } + } + + tokens.push(token); + + tokens +} + +#[cfg(test)] +mod tests { + //! Cover output-list matching for generated Ninja build statements. + + use super::edge_has_console_pool; + use anyhow::{Result, ensure}; + + #[test] + fn console_pool_lookup_matches_explicit_and_implicit_outputs() -> Result<()> { + let ninja = "build first second | implicit: command\n pool = console\n"; + + ensure!(edge_has_console_pool(ninja, "second")?); + ensure!(edge_has_console_pool(ninja, "implicit")?); + Ok(()) + } + + #[test] + fn console_pool_lookup_matches_escaped_spaces_in_outputs() -> Result<()> { + let ninja = "build first$ output: command\n pool = console\n"; + + ensure!(edge_has_console_pool(ninja, "first output")?); + Ok(()) + } + + #[test] + fn console_pool_lookup_does_not_match_a_partial_output() { + let ninja = "build target-extra: command\n pool = console\n"; + + assert!(edge_has_console_pool(ninja, "target").is_err()); + } +} From ce4cbf9b7a61f0bbc8a6b014382c234fb25a15af Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 27 Oct 2026 07:18:38 +0100 Subject: [PATCH 07/27] Fix streamed output and console validation (#946) Validate Ninja process options before recording build telemetry, and flush each fully written child-output chunk before reading the next one. Keep flush failures from retrying bytes already accepted by the destination. Add regression coverage for both progress policies, partial writes, and draining after a flush failure. Extract per-edge console checks so invalid custom-pool values retain their existing error path while the generator validation loop stays direct. --- src/ninja_gen/validation.rs | 58 ++++++----- src/runner/process/streaming/mod.rs | 60 +++++++++++- src/runner/process/streaming/tests.rs | 3 + .../process/streaming/tests/flush_tests.rs | 97 +++++++++++++++++++ .../tests/ninja_build_telemetry_tests.rs | 10 +- 5 files changed, 200 insertions(+), 28 deletions(-) create mode 100644 src/runner/process/streaming/tests/flush_tests.rs diff --git a/src/ninja_gen/validation.rs b/src/ninja_gen/validation.rs index 414bf3eed..f8d4e0086 100644 --- a/src/ninja_gen/validation.rs +++ b/src/ninja_gen/validation.rs @@ -3,7 +3,7 @@ use super::command_list::{CommandListEntry, CommandListEntryError, command_list_entry_error}; use super::{NinjaGenError, RecipeShell, escape::validate_ninja_value}; use crate::ast::{CommandIsolation, Recipe, StringOrList}; -use crate::ir::BuildGraph; +use crate::ir::{Action, BuildEdge, BuildGraph}; use crate::localization::{self, keys}; /// Reject console scheduling that conflicts with an edge's action. @@ -14,32 +14,42 @@ use crate::localization::{self, keys}; /// conflicts with an action's custom pool. pub(super) fn validate_console_scheduling(graph: &BuildGraph) -> Result<(), NinjaGenError> { for edge in graph.edges().filter(|edge| edge.console) { - let Some(action) = graph.actions.get(&edge.action_id) else { - continue; - }; - let output = edge - .explicit_outputs - .first() - .map_or_else(|| "".to_owned(), ToString::to_string); - if action.recipe.is_dependency_only() { - return Err(NinjaGenError::ConsoleDependencyOnly { - message: localization::message(keys::NINJA_GEN_CONSOLE_DEPENDENCY_ONLY) - .with_arg("output", &output), + if let Some(action) = graph.actions.get(&edge.action_id) { + validate_console_edge(edge, action)?; + } + } + Ok(()) +} + +/// Reject a console edge whose action conflicts with console scheduling. +/// +/// # Errors +/// +/// Returns an error for dependency-only recipes, conflicting pools, or invalid +/// Ninja pool values. +fn validate_console_edge(edge: &BuildEdge, action: &Action) -> Result<(), NinjaGenError> { + let output = edge + .explicit_outputs + .first() + .map_or_else(|| "".to_owned(), ToString::to_string); + if action.recipe.is_dependency_only() { + return Err(NinjaGenError::ConsoleDependencyOnly { + message: localization::message(keys::NINJA_GEN_CONSOLE_DEPENDENCY_ONLY) + .with_arg("output", &output), + output, + }); + } + if let Some(pool) = action.pool.as_deref() { + validate_ninja_value(pool)?; + if pool != "console" { + return Err(NinjaGenError::ConsolePoolConflict { + message: localization::message(keys::NINJA_GEN_CONSOLE_POOL_CONFLICT) + .with_arg("output", &output) + .with_arg("pool", pool), output, + pool: pool.to_owned(), }); } - if let Some(pool) = action.pool.as_deref() { - validate_ninja_value(pool)?; - if pool != "console" { - return Err(NinjaGenError::ConsolePoolConflict { - message: localization::message(keys::NINJA_GEN_CONSOLE_POOL_CONFLICT) - .with_arg("output", &output) - .with_arg("pool", pool), - output, - pool: pool.to_owned(), - }); - } - } } Ok(()) } diff --git a/src/runner/process/streaming/mod.rs b/src/runner/process/streaming/mod.rs index 64bbd6a57..21d48404c 100644 --- a/src/runner/process/streaming/mod.rs +++ b/src/runner/process/streaming/mod.rs @@ -196,6 +196,62 @@ fn clamp_u64_to_usize(value: u64) -> usize { usize::try_from(value).unwrap_or(usize::MAX) } +/// Read one child-output chunk, retrying interrupted reads. +/// +/// # Errors +/// +/// Returns an error when the reader fails. +fn read_next_chunk(reader: &mut R, buffer: &mut [u8]) -> io::Result +where + R: Read, +{ + loop { + match reader.read(buffer) { + Err(error) if error.kind() == io::ErrorKind::Interrupted => {} + result => break result, + } + } +} + +/// Write a complete child-output chunk and flush it to the destination. +/// +/// # Errors +/// +/// Returns an error when the writer rejects the chunk or its flush. +fn write_and_flush_chunk(writer: &mut W, chunk: &[u8]) -> io::Result<()> +where + W: Write, +{ + writer.write_all(chunk)?; + writer.flush() +} + +/// Copy and flush each child-read chunk before reading the next one. +/// +/// Keeping the flush separate from `Write::write` ensures a flush failure +/// cannot cause accepted bytes to be retried as another write. +/// +/// # Errors +/// +/// Returns the first error encountered while reading, writing, or flushing. +fn copy_and_flush(reader: &mut R, writer: &mut W) -> io::Result<()> +where + R: Read, + W: Write, +{ + let mut buffer = [0_u8; 8 * 1024]; + loop { + let count = read_next_chunk(reader, &mut buffer)?; + if count == 0 { + return Ok(()); + } + let chunk = buffer + .get(..count) + .ok_or_else(|| io::Error::other("reader reported an invalid byte count"))?; + write_and_flush_chunk(writer, chunk)?; + } +} + /// Copy `reader` to `writer`, tracking statistics and draining on failure. fn copy_with_stats(reader: &mut R, writer: &mut W, stream_name: &'static str) -> ForwardStats where @@ -212,8 +268,8 @@ where written: 0, }; - match io::copy(&mut counting_reader, &mut counting_writer) { - Ok(_) => { + match copy_and_flush(&mut counting_reader, &mut counting_writer) { + Ok(()) => { stats.bytes_read = clamp_u64_to_usize(counting_reader.read); stats.bytes_written = clamp_u64_to_usize(counting_writer.written); } diff --git a/src/runner/process/streaming/tests.rs b/src/runner/process/streaming/tests.rs index 163413870..3af38448e 100644 --- a/src/runner/process/streaming/tests.rs +++ b/src/runner/process/streaming/tests.rs @@ -16,6 +16,9 @@ use std::{ }, }; +#[path = "tests/flush_tests.rs"] +mod flush_tests; + #[derive(Clone)] struct FailingWriter { writes: Arc, diff --git a/src/runner/process/streaming/tests/flush_tests.rs b/src/runner/process/streaming/tests/flush_tests.rs new file mode 100644 index 000000000..e4830bae7 --- /dev/null +++ b/src/runner/process/streaming/tests/flush_tests.rs @@ -0,0 +1,97 @@ +//! Verify each forwarded output chunk becomes visible before the next read. + +use super::{ChunkedReader, forward_child_output}; +use std::io::{self, Write}; + +#[derive(Default)] +struct FlushBufferWriter { + pending: Vec, + visible: Vec, + snapshots: Vec>, + maximum_write: usize, +} + +impl FlushBufferWriter { + fn new(maximum_write: usize) -> Self { + Self { + maximum_write, + ..Self::default() + } + } +} + +impl Write for FlushBufferWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + let count = bytes.len().min(self.maximum_write); + let Some(prefix) = bytes.get(..count) else { + return Err(io::Error::other("writer prefix is outside the input")); + }; + self.pending.extend_from_slice(prefix); + Ok(count) + } + + fn flush(&mut self) -> io::Result<()> { + self.visible.append(&mut self.pending); + self.snapshots.push(self.visible.clone()); + Ok(()) + } +} + +#[test] +fn forward_output_flushes_each_complete_chunk_before_reading_more() { + let input = b"firstlast".to_vec(); + let reader = ChunkedReader::new(input.clone(), vec![5, 4]); + let mut writer = FlushBufferWriter::new(2); + + let stats = forward_child_output(reader, &mut writer, "stdout"); + + assert_eq!(stats.bytes_read, input.len()); + assert_eq!(stats.bytes_written, input.len()); + assert!(!stats.write_failed); + assert_eq!( + writer.snapshots, + vec![b"first".to_vec(), b"firstlast".to_vec()] + ); +} + +struct FailingFlushWriter { + accepted: Vec, + write_count: usize, + flush_count: usize, +} + +impl Write for FailingFlushWriter { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.write_count += 1; + self.accepted.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + self.flush_count += 1; + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "destination flush failed", + )) + } +} + +#[test] +fn flush_failure_keeps_accepted_bytes_and_drains_the_remaining_input() { + let input = b"firstlast".to_vec(); + let reader = ChunkedReader::new(input.clone(), vec![5, 4]); + let mut writer = FailingFlushWriter { + accepted: Vec::new(), + write_count: 0, + flush_count: 0, + }; + + let stats = forward_child_output(reader, &mut writer, "stdout"); + + assert_eq!(writer.accepted, b"first"); + assert_eq!(writer.write_count, 1, "accepted bytes must not be retried"); + assert_eq!(writer.flush_count, 1); + assert_eq!(stats.bytes_read, input.len(), "remaining bytes are drained"); + assert_eq!(stats.bytes_written, b"first".len()); + assert!(stats.write_failed); +} diff --git a/src/runner/tests/ninja_build_telemetry_tests.rs b/src/runner/tests/ninja_build_telemetry_tests.rs index 9b7eb2ed8..3afe4a99e 100644 --- a/src/runner/tests/ninja_build_telemetry_tests.rs +++ b/src/runner/tests/ninja_build_telemetry_tests.rs @@ -4,16 +4,22 @@ use super::*; use crate::runner::{NINJA_BUILD_EXECUTIONS_TOTAL, run_with_ninja_program}; use anyhow::{Result, ensure}; use metrics_util::debugging::DebuggingRecorder; +use rstest::rstest; use test_support::{localizer_test_lock, set_en_localizer}; -#[test] -fn invalid_progress_options_do_not_record_a_ninja_build() -> Result<()> { +#[rstest] +#[case::progress_enabled(crate::cli::ProgressPolicy::Auto)] +#[case::progress_disabled(crate::cli::ProgressPolicy::Never)] +fn invalid_job_options_do_not_record_a_ninja_build( + #[case] progress: crate::cli::ProgressPolicy, +) -> Result<()> { let _lock = localizer_test_lock().map_err(|error| anyhow::anyhow!("{error}"))?; let _guard = set_en_localizer(); let (_temp, manifest_path) = write_manifest(MINIMAL_MANIFEST)?; let cli = Cli { file: manifest_path, jobs: Some(0), + progress, ..Cli::default() }; let program = camino::Utf8Path::new("ninja"); From 404c600423e342b7321f9f4ea461f4ee95da8f56 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 27 Oct 2026 12:19:36 +0100 Subject: [PATCH 08/27] Assert streaming flush order before next read (#946) Record reader and writer events in the newline-free two-chunk regression and assert that each flush precedes the next read. The prior snapshot assertions could still pass if the copier read the second chunk before flushing the first. --- .../process/streaming/tests/flush_tests.rs | 48 +++++++++++++++++-- 1 file changed, 44 insertions(+), 4 deletions(-) diff --git a/src/runner/process/streaming/tests/flush_tests.rs b/src/runner/process/streaming/tests/flush_tests.rs index e4830bae7..4becfecad 100644 --- a/src/runner/process/streaming/tests/flush_tests.rs +++ b/src/runner/process/streaming/tests/flush_tests.rs @@ -1,7 +1,30 @@ //! Verify each forwarded output chunk becomes visible before the next read. use super::{ChunkedReader, forward_child_output}; -use std::io::{self, Write}; +use std::{ + cell::RefCell, + io::{self, Read, Write}, + rc::Rc, +}; + +#[derive(Debug, PartialEq, Eq)] +enum ForwardingEvent { + Read(usize), + Flush, +} + +struct ObservedReader { + inner: R, + events: Rc>>, +} + +impl Read for ObservedReader { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + let count = self.inner.read(buffer)?; + self.events.borrow_mut().push(ForwardingEvent::Read(count)); + Ok(count) + } +} #[derive(Default)] struct FlushBufferWriter { @@ -9,12 +32,14 @@ struct FlushBufferWriter { visible: Vec, snapshots: Vec>, maximum_write: usize, + events: Rc>>, } impl FlushBufferWriter { - fn new(maximum_write: usize) -> Self { + fn new(maximum_write: usize, events: Rc>>) -> Self { Self { maximum_write, + events, ..Self::default() } } @@ -31,6 +56,7 @@ impl Write for FlushBufferWriter { } fn flush(&mut self) -> io::Result<()> { + self.events.borrow_mut().push(ForwardingEvent::Flush); self.visible.append(&mut self.pending); self.snapshots.push(self.visible.clone()); Ok(()) @@ -40,14 +66,28 @@ impl Write for FlushBufferWriter { #[test] fn forward_output_flushes_each_complete_chunk_before_reading_more() { let input = b"firstlast".to_vec(); - let reader = ChunkedReader::new(input.clone(), vec![5, 4]); - let mut writer = FlushBufferWriter::new(2); + let events = Rc::default(); + let reader = ObservedReader { + inner: ChunkedReader::new(input.clone(), vec![5, 4]), + events: Rc::clone(&events), + }; + let mut writer = FlushBufferWriter::new(2, Rc::clone(&events)); let stats = forward_child_output(reader, &mut writer, "stdout"); assert_eq!(stats.bytes_read, input.len()); assert_eq!(stats.bytes_written, input.len()); assert!(!stats.write_failed); + assert_eq!( + *events.borrow(), + vec![ + ForwardingEvent::Read(5), + ForwardingEvent::Flush, + ForwardingEvent::Read(4), + ForwardingEvent::Flush, + ForwardingEvent::Read(0), + ] + ); assert_eq!( writer.snapshots, vec![b"first".to_vec(), b"firstlast".to_vec()] From 185728650322ef8cafc43d91faf24236a66778e1 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 28 Oct 2026 12:14:48 +0100 Subject: [PATCH 09/27] Repair console integration after rebase (#946) Keep ordinary command-list fixtures compatible with the console edge flag, and retain coverage for the existing telemetry error category. Move the progress callback into the reporter module to satisfy the runner module size limit while preserving its behaviour. --- src/runner/dyndep/generation_telemetry.rs | 8 +++++--- src/runner/mod.rs | 11 ++--------- src/runner/process/test_fixtures.rs | 1 + src/runner/process/tests/command_list_targets.rs | 1 + src/runner/reporter.rs | 11 ++++++++++- 5 files changed, 19 insertions(+), 13 deletions(-) diff --git a/src/runner/dyndep/generation_telemetry.rs b/src/runner/dyndep/generation_telemetry.rs index ffe1603f1..c0002f350 100644 --- a/src/runner/dyndep/generation_telemetry.rs +++ b/src/runner/dyndep/generation_telemetry.rs @@ -254,10 +254,11 @@ mod tests { graph } - /// Build the controlled console error used to verify finite categories. - fn console_error(category: &str) -> NinjaGenError { + /// Build a controlled generation error with the requested fixed category. + fn generation_error(category: &str) -> NinjaGenError { use crate::localization::{self, keys}; match category { + "command_list" => NinjaGenError::EmptyCommandRecipe { action_index: 1 }, "console_dependency_only" => NinjaGenError::ConsoleDependencyOnly { output: "private-output".into(), message: localization::message(keys::NINJA_GEN_CONSOLE_DEPENDENCY_ONLY) @@ -302,6 +303,7 @@ mod tests { #[rstest] #[case::ordinary_success(0, "disabled", "none")] #[case::console_success(2, "enabled", "none")] + #[case::command_list_error(0, "disabled", "command_list")] #[case::dependency_only_error(2, "enabled", "console_dependency_only")] #[case::pool_conflict_error(2, "enabled", "console_pool_conflict")] fn generation_records_console_shape_and_bounded_outcomes( @@ -318,7 +320,7 @@ mod tests { if category == "none" { Ok(()) } else { - Err(console_error(category)) + Err(generation_error(category)) } }) }); diff --git a/src/runner/mod.rs b/src/runner/mod.rs index e19606c43..80aacbc0c 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -184,13 +184,6 @@ fn run_with_ninja_program_resolver( dispatch::execute(cli, command, &context) } -/// Adapt the reporter's task-progress method into a mutable callback. -fn on_task_progress_callback(reporter: &dyn StatusReporter) -> impl FnMut(u32, u32, &str) + '_ { - move |current: u32, total: u32, description: &str| { - reporter.report_task_progress(current, total, description); - } -} - /// Resolve the manifest, generate the Ninja file and invoke the build. /// /// # Errors @@ -256,7 +249,7 @@ fn execute_build( }; let ninja_result = console_telemetry::instrument_ninja_build(console_edge_count, || { if context.progress_enabled { - let mut on_task_progress = on_task_progress_callback(context.reporter); + let mut on_task_progress = reporter::on_task_progress_callback(context.reporter); process::run_ninja_with_command_list_targets( build_request, Some(&mut on_task_progress), @@ -352,7 +345,7 @@ fn execute_ninja_tool( }; if context.progress_enabled { let options = ninja::ninja_process_options(cli)?; - let mut on_task_progress = on_task_progress_callback(context.reporter); + let mut on_task_progress = reporter::on_task_progress_callback(context.reporter); process::run_ninja_tool_with_status( process::NinjaToolRequest { program: context.ninja_program, diff --git a/src/runner/process/test_fixtures.rs b/src/runner/process/test_fixtures.rs index d10e1a8f7..fc43408e3 100644 --- a/src/runner/process/test_fixtures.rs +++ b/src/runner/process/test_fixtures.rs @@ -49,6 +49,7 @@ pub(super) fn insert_edge( progress_label: None, phony, always: false, + console: false, }); assert!(result.is_ok(), "test edge outputs should be unique"); } diff --git a/src/runner/process/tests/command_list_targets.rs b/src/runner/process/tests/command_list_targets.rs index 721398cba..d30dfa3a7 100644 --- a/src/runner/process/tests/command_list_targets.rs +++ b/src/runner/process/tests/command_list_targets.rs @@ -84,6 +84,7 @@ fn implicit_output_is_used_when_an_edge_has_no_explicit_output( progress_label: None, phony: false, always: false, + console: false, }); assert!(result.is_ok(), "test edge outputs should be unique"); diff --git a/src/runner/reporter.rs b/src/runner/reporter.rs index 7692aefe4..3b6893145 100644 --- a/src/runner/reporter.rs +++ b/src/runner/reporter.rs @@ -1,4 +1,4 @@ -//! Construction of the run's [`StatusReporter`] from resolved output settings. +//! Construct reporters and adapt their progress updates for Ninja execution. use crate::output_mode::OutputMode; use crate::output_prefs::OutputPrefs; @@ -50,6 +50,15 @@ const fn should_force_text_task_updates(mode: OutputMode, stdout_is_tty: bool) - mode.is_accessible() || !stdout_is_tty } +/// Adapt the reporter's task-progress method into a mutable callback. +pub(super) fn on_task_progress_callback( + reporter: &dyn StatusReporter, +) -> impl FnMut(u32, u32, &str) + '_ { + move |current: u32, total: u32, description: &str| { + reporter.report_task_progress(current, total, description); + } +} + #[cfg(test)] mod tests { //! Unit tests for the forced-text-update predicate. From ca486c7003d789039ab324e008e9bf7f8284b12f Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 28 Oct 2026 12:42:01 +0100 Subject: [PATCH 10/27] Consolidate console test fixtures (#946) Share a scoped edge factory across generator tests and report invalid telemetry fixture categories through the test result. Document the factory's test-only ownership. --- docs/developers-guide.md | 4 + src/ninja_gen/tests/console.rs | 92 +++++++++-------------- src/runner/dyndep/generation_telemetry.rs | 40 ++++++---- 3 files changed, 65 insertions(+), 71 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 9f39def68..036d52bcd 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -7345,6 +7345,10 @@ generated text used by these tests and does not parse general Ninja syntax. Permitted call sites are test-support and test code only; production code must not depend on `test_support`. +The local `edge_factory` fixture in `src/ninja_gen/tests/console.rs` constructs +parallel, single-output edges for console generator tests. Keep it within that +module; other graph shapes use their own fixtures. + ### Temporary Ninja build files `runner::process::create_temp_ninja_file` writes, flushes, and synchronizes a diff --git a/src/ninja_gen/tests/console.rs b/src/ninja_gen/tests/console.rs index 3c96fb2de..d4fc6c467 100644 --- a/src/ninja_gen/tests/console.rs +++ b/src/ninja_gen/tests/console.rs @@ -5,28 +5,39 @@ use super::{NinjaGenError, generate, generate_bundle, generate_posix}; use crate::ir::{BuildEdge, BuildGraph, DependencyOrder}; use anyhow::{Result, ensure}; use camino::Utf8PathBuf; +use rstest::{fixture, rstest}; + +type EdgeFactory = fn(&str, &str, bool) -> BuildEdge; + +/// Construct parallel, single-output edges for console generator tests. +#[fixture] +fn edge_factory() -> EdgeFactory { + |action_id, output, console| BuildEdge { + action_id: action_id.into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec![Utf8PathBuf::from(output)], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + console, + } +} /// Emit console selection on one edge while reusing the action's rule normally. -#[test] -fn console_pool_is_edge_level_and_keeps_shared_action_rules() -> Result<()> { +#[rstest] +fn console_pool_is_edge_level_and_keeps_shared_action_rules( + edge_factory: EdgeFactory, +) -> Result<()> { let mut graph = BuildGraph::default(); graph .actions .insert("shared".into(), command_action("echo ready".into())); for (output, console) in [("console.out", true), ("ordinary.out", false)] { graph - .insert_edge(BuildEdge { - action_id: "shared".into(), - inputs: Vec::new(), - implicit_deps: Vec::new(), - dependency_order: DependencyOrder::Parallel, - explicit_outputs: vec![Utf8PathBuf::from(output)], - implicit_outputs: Vec::new(), - order_only_deps: Vec::new(), - phony: false, - always: false, - console, - }) + .insert_edge(edge_factory("shared", output, console)) .expect("test graph output aliases must be unique"); } @@ -46,26 +57,15 @@ fn console_pool_is_edge_level_and_keeps_shared_action_rules() -> Result<()> { } /// Clear rule-level console scheduling from an edge without the opt-in. -#[test] -fn ordinary_edge_clears_a_shared_console_action_pool() -> Result<()> { +#[rstest] +fn ordinary_edge_clears_a_shared_console_action_pool(edge_factory: EdgeFactory) -> Result<()> { let mut graph = BuildGraph::default(); let mut action = command_action("echo ready".into()); action.pool = Some("console".into()); graph.actions.insert("shared".into(), action); for (output, console) in [("console.out", true), ("ordinary.out", false)] { graph - .insert_edge(BuildEdge { - action_id: "shared".into(), - inputs: Vec::new(), - implicit_deps: Vec::new(), - dependency_order: DependencyOrder::Parallel, - explicit_outputs: vec![Utf8PathBuf::from(output)], - implicit_outputs: Vec::new(), - order_only_deps: Vec::new(), - phony: false, - always: false, - console, - }) + .insert_edge(edge_factory("shared", output, console)) .expect("test graph output aliases must be unique"); } @@ -82,25 +82,14 @@ fn ordinary_edge_clears_a_shared_console_action_pool() -> Result<()> { } /// Accept the redundant combination of an edge flag and `pool = console` metadata. -#[test] -fn console_pool_accepts_matching_action_pool() -> Result<()> { +#[rstest] +fn console_pool_accepts_matching_action_pool(edge_factory: EdgeFactory) -> Result<()> { let mut graph = BuildGraph::default(); let mut action = command_action("echo ready".into()); action.pool = Some("console".into()); graph.actions.insert("shared".into(), action); graph - .insert_edge(BuildEdge { - action_id: "shared".into(), - inputs: Vec::new(), - implicit_deps: Vec::new(), - dependency_order: DependencyOrder::Parallel, - explicit_outputs: vec![Utf8PathBuf::from("console.out")], - implicit_outputs: Vec::new(), - order_only_deps: Vec::new(), - phony: false, - always: false, - console: true, - }) + .insert_edge(edge_factory("shared", "console.out", true)) .expect("test graph output aliases must be unique"); let ninja = generate_posix(&graph)?; @@ -112,22 +101,13 @@ fn console_pool_accepts_matching_action_pool() -> Result<()> { } /// Preserve the existing missing-action error when a console edge has no action. -#[test] -fn console_validation_leaves_missing_actions_to_generation() -> Result<()> { +#[rstest] +fn console_validation_leaves_missing_actions_to_generation( + edge_factory: EdgeFactory, +) -> Result<()> { let mut graph = BuildGraph::default(); graph - .insert_edge(BuildEdge { - action_id: "missing".into(), - inputs: Vec::new(), - implicit_deps: Vec::new(), - dependency_order: DependencyOrder::Parallel, - explicit_outputs: vec![Utf8PathBuf::from("console.out")], - implicit_outputs: Vec::new(), - order_only_deps: Vec::new(), - phony: false, - always: false, - console: true, - }) + .insert_edge(edge_factory("missing", "console.out", true)) .expect("test graph output aliases must be unique"); let generate_error = generate(&graph).expect_err("missing action must remain an error"); diff --git a/src/runner/dyndep/generation_telemetry.rs b/src/runner/dyndep/generation_telemetry.rs index c0002f350..3a5153e5e 100644 --- a/src/runner/dyndep/generation_telemetry.rs +++ b/src/runner/dyndep/generation_telemetry.rs @@ -255,9 +255,13 @@ mod tests { } /// Build a controlled generation error with the requested fixed category. - fn generation_error(category: &str) -> NinjaGenError { + /// + /// # Errors + /// + /// Returns an error when the category is not a supported fixture value. + fn generation_error(category: &str) -> anyhow::Result { use crate::localization::{self, keys}; - match category { + Ok(match category { "command_list" => NinjaGenError::EmptyCommandRecipe { action_index: 1 }, "console_dependency_only" => NinjaGenError::ConsoleDependencyOnly { output: "private-output".into(), @@ -271,8 +275,8 @@ mod tests { .with_arg("output", "private-output") .with_arg("pool", "private-pool"), }, - _ => panic!("fixture must name a known category"), - } + _ => anyhow::bail!("unknown generation fixture category: {category}"), + }) } /// Assert one bounded operation, one duration sample, and the configured-edge count. @@ -310,19 +314,18 @@ mod tests { #[case] console_edges: usize, #[case] scheduling: &str, #[case] category: &str, - ) { + ) -> anyhow::Result<()> { let recorder = DebuggingRecorder::new(); let snapshotter = recorder.snapshotter(); let graph = graph_with_console_edges(console_edges); + let error = if category == "none" { + None + } else { + Some(generation_error(category)?) + }; let (result, fields) = with_span_capture(|captured| { let result = metrics::with_local_recorder(&recorder, || { - instrument_bundle_generation(&graph, || { - if category == "none" { - Ok(()) - } else { - Err(generation_error(category)) - } - }) + instrument_bundle_generation(&graph, || error.map_or(Ok(()), Err)) }); (result, captured.snapshot()) }); @@ -331,7 +334,10 @@ mod tests { } else { "error" }; - assert_eq!(result.is_err(), category != "none"); + anyhow::ensure!( + result.is_err() == (category != "none"), + "unexpected generation result for {category}: {result:?}" + ); let snapshot = snapshotter.snapshot().into_vec(); let labels = [ ("outcome", outcome), @@ -345,11 +351,15 @@ mod tests { format!("outcome={outcome:?}"), format!("error_category={category:?}"), ] { - assert!( + anyhow::ensure!( fields.contains(&expected), "span must record {expected}: {fields:?}" ); } - assert!(fields.iter().all(|field| !field.contains("private-"))); + anyhow::ensure!( + fields.iter().all(|field| !field.contains("private-")), + "span fields must not include private values: {fields:?}" + ); + Ok(()) } } From a4b8b6691ead5b347dadde98e3862cfb8bfd967a Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 28 Oct 2026 14:12:19 +0100 Subject: [PATCH 11/27] Verify console telemetry at the runner boundary (#946) Inject the existing monotonic clock into Ninja build telemetry and test its exact duration. Exercise console edge counts through the runner and across generated serial graphs, and correct the developer guide's path. --- docs/developers-guide.md | 14 +++-- src/ninja_gen/property_tests/console.rs | 62 +++++++++++++++++++ src/ninja_gen/property_tests/mod.rs | 1 + src/runner/console_telemetry/mod.rs | 17 ++--- src/runner/console_telemetry/tests.rs | 9 ++- src/runner/graph/generation/mod.rs | 6 +- src/runner/mod.rs | 3 +- tests/runner_legacy_recipe_telemetry_tests.rs | 62 ++++++++++++++++++- 8 files changed, 153 insertions(+), 21 deletions(-) create mode 100644 src/ninja_gen/property_tests/console.rs diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 036d52bcd..315108383 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -6626,11 +6626,12 @@ or add manifest-controlled values to telemetry. The runner-internal `GraphGenerationContext` in `src/runner/graph/generation/mod.rs` groups the selected `RecipeShell`, an -optional preflighted `BashExecutable`, and injected monotonic clock solely for -this graph-generation composition path. Build and Ninja-tool operations attach -the runtime binding after preflight; query-only generation leaves it absent. It -is not a general runner context, shared state container, or reusable public -API; keep unrelated runner inputs and concerns outside it. +optional preflighted `BashExecutable`, and injected monotonic clock for the +graph-generation composition path. The build runner also uses that clock to +measure its Ninja invocation. Build and Ninja-tool operations attach the +runtime binding after preflight; query-only generation leaves it absent. It is +not a general runner context, shared state container, or reusable public API; +keep unrelated runner inputs and concerns outside it. The intended serial guarantee is path-scoped. A later dependency that is independently reachable elsewhere in the requested graph may start via that @@ -10290,13 +10291,14 @@ fields. ### Module: `runner::console_telemetry` -`src/runner/console_telemetry.rs` instruments only the actual Ninja build +`src/runner/console_telemetry/mod.rs` instruments only the actual Ninja build invocation in `execute_build`. It supplements the complete legacy-recipe operation timing with subprocess duration and outcome grouped by `console_scheduling=enabled|disabled`. Its `runner.ninja.build` span and completion event include the aggregate `console_edge_count`, scheduling state, `outcome=success|error`, and `error_category=none|io`. Paths, action identities, recipe contents, and process error text never enter this boundary. +The runner's injected monotonic clock supplies the duration measurement. The generator stores the count of selected user edges privately in `GeneratedNinja`; the runner reads that aggregate before consuming the bundle. diff --git a/src/ninja_gen/property_tests/console.rs b/src/ninja_gen/property_tests/console.rs new file mode 100644 index 000000000..3498dcf8f --- /dev/null +++ b/src/ninja_gen/property_tests/console.rs @@ -0,0 +1,62 @@ +//! Check console scheduling across generated user and serial edges. + +use super::{generate_bundle, serial_graph}; +use crate::ir::DependencyOrder; +use camino::{Utf8Path, Utf8PathBuf}; +use proptest::prelude::*; +use test_support::ninja_edge::edge_has_console_pool; + +proptest! { + #![proptest_config(ProptestConfig { cases: 64, .. ProptestConfig::default() })] + + #[test] + fn console_bindings_match_user_edges( + selections in prop::collection::vec(any::(), 2..7), + parent_console in any::(), + ) { + let outputs: Vec<_> = (0..selections.len()) + .map(|index| Utf8PathBuf::from(format!("dep{index}"))) + .collect(); + let mut graph = serial_graph(outputs.clone()); + let mut parent = graph + .target_for_output(Utf8Path::new("aggregate")) + .map(|(_, edge)| edge.clone()) + .ok_or_else(|| TestCaseError::fail("serial parent must exist"))?; + parent.console = parent_console; + prop_assert!(graph.replace_edge_for_output(Utf8Path::new("aggregate"), parent.clone())); + + for (output, selected) in outputs.iter().zip(&selections) { + let mut edge = parent.clone(); + edge.explicit_outputs = vec![output.clone()]; + edge.implicit_deps.clear(); + edge.dependency_order = DependencyOrder::Parallel; + edge.console = *selected; + prop_assert!(graph.insert_edge(edge).is_ok()); + } + + let bundle = generate_bundle(&graph) + .map_err(|error| TestCaseError::fail(error.to_string()))?; + let file = bundle.build_file(); + for (output, selected) in outputs.iter().zip(&selections) { + let actual = edge_has_console_pool(file, output.as_str()) + .map_err(|error| TestCaseError::fail(error.to_string()))?; + prop_assert_eq!(actual, *selected); + } + let parent_actual = edge_has_console_pool(file, "aggregate") + .map_err(|error| TestCaseError::fail(error.to_string()))?; + prop_assert_eq!(parent_actual, parent_console); + let selected_count = selections.iter().filter(|selected| **selected).count() + + usize::from(parent_console); + prop_assert_eq!(bundle.console_edge_count(), selected_count); + prop_assert_eq!( + file.lines().filter(|line| *line == " pool = console").count(), + selected_count + ); + prop_assert!( + bundle + .dyndep_files() + .iter() + .all(|sidecar| !sidecar.content().contains("pool = console")) + ); + } +} diff --git a/src/ninja_gen/property_tests/mod.rs b/src/ninja_gen/property_tests/mod.rs index 3018a7edb..f368d5f7b 100644 --- a/src/ninja_gen/property_tests/mod.rs +++ b/src/ninja_gen/property_tests/mod.rs @@ -18,6 +18,7 @@ use crate::{ use camino::Utf8PathBuf; use std::collections::{HashMap, HashSet}; +mod console; mod dependency_only; mod ninja_oracle; diff --git a/src/runner/console_telemetry/mod.rs b/src/runner/console_telemetry/mod.rs index b79856e2d..0971df55b 100644 --- a/src/runner/console_telemetry/mod.rs +++ b/src/runner/console_telemetry/mod.rs @@ -4,11 +4,8 @@ //! closure. It emits no paths, identifiers, commands, or process error text. use metrics::{counter, describe_counter, describe_histogram, histogram}; -use std::{ - io, - sync::Once, - time::{Duration, Instant}, -}; +use monotony::MonotonicClock; +use std::{io, sync::Once, time::Duration}; use tracing::field; /// Metric counting actual runner-owned Ninja build invocations. @@ -37,6 +34,7 @@ pub(in crate::runner) const fn scheduling_label(console_edge_count: usize) -> &' /// /// Returns the invocation's I/O error unchanged when Ninja execution fails. pub(in crate::runner) fn instrument_ninja_build( + clock: &dyn MonotonicClock, console_edge_count: usize, invoke: impl FnOnce() -> io::Result, ) -> io::Result { @@ -50,9 +48,14 @@ pub(in crate::runner) fn instrument_ninja_build( error_category = field::Empty, ); let _guard = span.enter(); - let started = Instant::now(); + let started = clock.now(); let result = invoke(); - record_result(&span, console_edge_count, started.elapsed(), &result); + record_result( + &span, + console_edge_count, + clock.now().duration_since(started), + &result, + ); result } diff --git a/src/runner/console_telemetry/tests.rs b/src/runner/console_telemetry/tests.rs index b1b25b9a7..7bc04769b 100644 --- a/src/runner/console_telemetry/tests.rs +++ b/src/runner/console_telemetry/tests.rs @@ -6,9 +6,13 @@ use metrics_util::{ CompositeKey, MetricKind, debugging::{DebugValue, DebuggingRecorder}, }; +use monotony::test_util::FixedMonotonicClock; use rstest::rstest; use tracing_subscriber::filter::LevelFilter; +/// Duration reported by the injected clock for each test invocation. +const BUILD_ELAPSED: Duration = Duration::from_millis(1_250); + /// Represent the local recorder's drained metric series. type Snapshot = Vec<( CompositeKey, @@ -55,7 +59,7 @@ fn assert_metrics(snapshot: &Snapshot, console_edges: usize, labels: [(&str, &st Some(DebugValue::Counter(1)) )); assert!( - matches!(metric(snapshot, NINJA_BUILD_EXECUTION_DURATION, MetricKind::Histogram, &labels), Some(DebugValue::Histogram(samples)) if samples.len() == 1) + matches!(metric(snapshot, NINJA_BUILD_EXECUTION_DURATION, MetricKind::Histogram, &labels), Some(DebugValue::Histogram(samples)) if samples.iter().map(|sample| sample.0).eq([BUILD_ELAPSED.as_secs_f64()])) ); let outcome = labels[1].1; assert!( @@ -80,9 +84,10 @@ fn invocation_records_bounded_scheduling_outcome_and_duration( }; let recorder = DebuggingRecorder::new(); let snapshotter = recorder.snapshotter(); + let clock = FixedMonotonicClock::with_elapsed(BUILD_ELAPSED); let (result, events) = metrics::with_local_recorder(&recorder, || { with_test_subscriber(LevelFilter::INFO, |captured| { - let result = instrument_ninja_build(console_edges, || { + let result = instrument_ninja_build(&clock, console_edges, || { if fail { Err(io::Error::other("private process detail")) } else { diff --git a/src/runner/graph/generation/mod.rs b/src/runner/graph/generation/mod.rs index 5bd250681..fa6d17a04 100644 --- a/src/runner/graph/generation/mod.rs +++ b/src/runner/graph/generation/mod.rs @@ -19,14 +19,14 @@ use monotony::MonotonicClock; /// Supply the dependencies that select and measure graph generation. /// -/// Keep this runner-internal composition boundary limited to graph generation -/// so unrelated command dispatch does not acquire a clock dependency. +/// Keep this runner-internal composition boundary limited to the build +/// pipeline. The same clock also measures its Ninja invocation. pub(in crate::runner) struct GraphGenerationContext<'a> { /// Select the legacy-recipe interpreter used during graph generation. pub(in crate::runner) recipe_shell: crate::recipe_shell::RecipeShell, /// Bind builds to the Bash executable that passed runner preflight. pub(in crate::runner) bash_executable: Option, - /// Measure graph generation with a runner-provided monotonic clock. + /// Measure graph generation and Ninja execution with a runner-provided clock. pub(in crate::runner) clock: &'a dyn MonotonicClock, } diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 80aacbc0c..24e4781cc 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -247,7 +247,8 @@ fn execute_build( env: &env, stderr_mode: StderrMode::from_json_enabled(cli.json), }; - let ninja_result = console_telemetry::instrument_ninja_build(console_edge_count, || { + let clock = graph_generation.clock; + let ninja_result = console_telemetry::instrument_ninja_build(clock, console_edge_count, || { if context.progress_enabled { let mut on_task_progress = reporter::on_task_progress_callback(context.reporter); process::run_ninja_with_command_list_targets( diff --git a/tests/runner_legacy_recipe_telemetry_tests.rs b/tests/runner_legacy_recipe_telemetry_tests.rs index 65aef764d..e7a56a254 100644 --- a/tests/runner_legacy_recipe_telemetry_tests.rs +++ b/tests/runner_legacy_recipe_telemetry_tests.rs @@ -1,4 +1,4 @@ -//! Runner-boundary coverage for bounded legacy-recipe operation telemetry. +//! Runner-boundary coverage for bounded recipe and console telemetry. //! //! Each case invokes [`netsuke::runner::run_with_ninja_program`] with an //! injected executable and local metrics recorder. This verifies the complete @@ -13,8 +13,12 @@ use metrics_util::{ use netsuke::cli::{Cli, Commands}; use netsuke::output_prefs; use netsuke::runner::{ - LEGACY_RECIPE_EXECUTION_DURATION, LEGACY_RECIPE_EXECUTIONS_TOTAL, run_with_ninja_program, + BUNDLE_CONSOLE_EDGES_TOTAL, BUNDLE_GENERATION_DURATION, BUNDLE_GENERATIONS_TOTAL, + LEGACY_RECIPE_EXECUTION_DURATION, LEGACY_RECIPE_EXECUTIONS_TOTAL, + NINJA_BUILD_CONSOLE_EDGES_TOTAL, NINJA_BUILD_EXECUTION_DURATION, NINJA_BUILD_EXECUTIONS_TOTAL, + run_with_ninja_program, }; +use rstest::rstest; use std::path::{Path, PathBuf}; use test_support::check_ninja::{ToolName, fake_ninja_check_build_file, fake_ninja_expect_tool}; use test_support::fs as test_fs; @@ -135,6 +139,60 @@ fn build_dispatch_records_successful_legacy_recipe_operation() -> Result<()> { Ok(()) } +/// Verify both console-edge counters carry the expected bounded outcome. +fn assert_console_edge_counts(snapshot: &Snapshot, expected_edges: u64) -> Result<()> { + for name in [NINJA_BUILD_CONSOLE_EDGES_TOTAL, BUNDLE_CONSOLE_EDGES_TOTAL] { + let matching = snapshot + .iter() + .filter(|(key, _, _, _)| key.kind() == MetricKind::Counter && key.key().name() == name) + .collect::>(); + ensure!( + matching.len() == 1 + && matching.first().is_some_and(|(key, _, _, value)| { + key.key().labels().count() == 1 + && key + .key() + .labels() + .any(|label| label.key() == "outcome" && label.value() == "success") + && matches!(value, DebugValue::Counter(count) if *count == expected_edges) + }), + "expected {expected_edges} configured edges for {name}: {snapshot:?}" + ); + } + Ok(()) +} + +#[rstest] +#[case::ordinary(include_str!("data/minimal.yml"), "disabled", 0)] +#[case::console(include_str!("data/console.yml"), "enabled", 2)] +fn build_dispatch_records_console_edge_counts( + #[case] manifest: &str, + #[case] scheduling: &str, + #[case] expected_edges: u64, +) -> Result<()> { + let (_ninja_directory, ninja_program) = fake_ninja_check_build_file()?; + let (directory, manifest_path) = temporary_manifest(manifest)?; + let cli = runner_cli(directory.path(), manifest_path, None)?; + let (result, snapshot) = capture_operation(&cli, &ninja_program); + result.context("the injected Ninja build should succeed")?; + + let labels = [ + ("console_scheduling", scheduling), + ("outcome", "success"), + ("error_category", "none"), + ]; + for (kind, name) in [ + (MetricKind::Counter, NINJA_BUILD_EXECUTIONS_TOTAL), + (MetricKind::Histogram, NINJA_BUILD_EXECUTION_DURATION), + (MetricKind::Counter, BUNDLE_GENERATIONS_TOTAL), + (MetricKind::Histogram, BUNDLE_GENERATION_DURATION), + ] { + assert_metric(&snapshot, kind, name, &labels); + } + assert_console_edge_counts(&snapshot, expected_edges)?; + Ok(()) +} + /// Verify the Ninja-tool dispatch path records one successful operation. #[test] fn ninja_tool_dispatch_records_successful_legacy_recipe_operation() -> Result<()> { From 8fde80d3cf77f62fe9b48b2705e3b5f9f3ec425b Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 28 Oct 2026 16:14:16 +0100 Subject: [PATCH 12/27] Integrate console edges with progress labels (#946) Preserve console scheduling alongside inline progress descriptions after rebasing. Update public fixtures and the generated snapshot, and assign the console decision the next available ADR number. --- docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md | 2 +- src/localization/keys.rs | 2 -- src/ninja_gen/dyndep/tests/console.rs | 1 + src/ninja_gen/progress_label.rs | 1 + src/ninja_gen/tests/console.rs | 1 + src/ninja_gen/validation.rs | 1 + src/runner/dyndep/generation_telemetry.rs | 1 + tests/bdd/steps/ninja.rs | 1 + tests/ninja_gen_inline_progress_process_tests.rs | 1 + tests/ninja_gen_integration_tests/progress_localization.rs | 1 + .../ninja/ninja_snapshot_tests__console_manifest_ninja.snap | 1 + tests/ui/build_edge_console_compile_pass.rs | 1 + 12 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md b/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md index 587ce0aed..0ad5ccb18 100644 --- a/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md +++ b/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md @@ -1,4 +1,4 @@ -# Architectural decision record (ADR) 0060: Select Ninja's console pool per build edge +# Architectural decision record (ADR) 0061: Select Ninja's console pool per build edge ## Status diff --git a/src/localization/keys.rs b/src/localization/keys.rs index 1c4a811c8..982058310 100644 --- a/src/localization/keys.rs +++ b/src/localization/keys.rs @@ -1,5 +1,4 @@ //! Fluent message identifiers used by Netsuke. -//! //! This file holds the general key table: the build-time localization audit //! parses its `define_keys!` invocation, and the macro itself is defined in //! the parent module. The `netsuke check` keys live in `check_keys.rs`, the @@ -31,7 +30,6 @@ define_keys! { CLI_FLAG_FETCH_BLOCK_HOST_HELP => "cli.flag.fetch_block_host.help", CLI_FLAG_FETCH_DEFAULT_DENY_HELP => "cli.flag.fetch_default_deny.help", CLI_FLAG_TRUST_PROJECT_FETCH_POLICY_HELP => "cli.flag.trust_project_fetch_policy.help", - CLI_FLAG_MANIFEST_EVALUATION_FUEL_HELP => "cli.flag.manifest_evaluation_fuel.help", CLI_FLAG_MANIFEST_FUEL_HELP => "cli.flag.manifest_fuel.help", CLI_FLAG_MANIFEST_RENDERED_VALUE_BYTES_HELP => "cli.flag.manifest_rendered_value_bytes.help", diff --git a/src/ninja_gen/dyndep/tests/console.rs b/src/ninja_gen/dyndep/tests/console.rs index 776896f8e..37d25ecd1 100644 --- a/src/ninja_gen/dyndep/tests/console.rs +++ b/src/ninja_gen/dyndep/tests/console.rs @@ -68,6 +68,7 @@ fn ordinary_dyndep_edge_clears_a_shared_console_action_pool() -> Result<()> { explicit_outputs: vec![Utf8PathBuf::from("ordinary")], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), + progress_label: None, phony: false, always: false, console: false, diff --git a/src/ninja_gen/progress_label.rs b/src/ninja_gen/progress_label.rs index 949256a60..ad53b279f 100644 --- a/src/ninja_gen/progress_label.rs +++ b/src/ninja_gen/progress_label.rs @@ -121,6 +121,7 @@ mod tests { progress_label: progress_label.map(str::to_owned), phony: false, always: false, + console: false, } } diff --git a/src/ninja_gen/tests/console.rs b/src/ninja_gen/tests/console.rs index d4fc6c467..85bb15f48 100644 --- a/src/ninja_gen/tests/console.rs +++ b/src/ninja_gen/tests/console.rs @@ -20,6 +20,7 @@ fn edge_factory() -> EdgeFactory { explicit_outputs: vec![Utf8PathBuf::from(output)], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), + progress_label: None, phony: false, always: false, console, diff --git a/src/ninja_gen/validation.rs b/src/ninja_gen/validation.rs index f8d4e0086..53cf79dc6 100644 --- a/src/ninja_gen/validation.rs +++ b/src/ninja_gen/validation.rs @@ -198,6 +198,7 @@ mod tests { explicit_outputs: vec![Utf8PathBuf::from("console.out")], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), + progress_label: None, phony: false, always: false, console: true, diff --git a/src/runner/dyndep/generation_telemetry.rs b/src/runner/dyndep/generation_telemetry.rs index 3a5153e5e..7e555fd0a 100644 --- a/src/runner/dyndep/generation_telemetry.rs +++ b/src/runner/dyndep/generation_telemetry.rs @@ -245,6 +245,7 @@ mod tests { explicit_outputs: vec![Utf8PathBuf::from(format!("private-{index}.out"))], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), + progress_label: None, phony: false, always: false, console: index < console_edges, diff --git a/tests/bdd/steps/ninja.rs b/tests/bdd/steps/ninja.rs index 65d4b8035..08265161f 100644 --- a/tests/bdd/steps/ninja.rs +++ b/tests/bdd/steps/ninja.rs @@ -51,6 +51,7 @@ fn dependency_only_console_graph(world: &TestWorld) -> Result<()> { explicit_outputs: vec!["all".into()], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), + progress_label: None, phony: true, always: false, console: true, diff --git a/tests/ninja_gen_inline_progress_process_tests.rs b/tests/ninja_gen_inline_progress_process_tests.rs index 5252c7df7..a8d561129 100644 --- a/tests/ninja_gen_inline_progress_process_tests.rs +++ b/tests/ninja_gen_inline_progress_process_tests.rs @@ -54,6 +54,7 @@ fn run_action( progress_label: progress_label.map(str::to_owned), phony: false, always: false, + console: false, }; let mut graph = BuildGraph::default(); graph.actions.insert(edge.action_id.clone(), action); diff --git a/tests/ninja_gen_integration_tests/progress_localization.rs b/tests/ninja_gen_integration_tests/progress_localization.rs index 38d24fc2c..1df88c9da 100644 --- a/tests/ninja_gen_integration_tests/progress_localization.rs +++ b/tests/ninja_gen_integration_tests/progress_localization.rs @@ -104,6 +104,7 @@ fn arabic_generation_preserves_fluent_isolates_and_sanitizes_input() -> Result<( progress_label: Some("left\u{202e}right".into()), phony: false, always: false, + console: false, }; let mut graph = BuildGraph::default(); graph.actions.insert(edge.action_id.clone(), action); diff --git a/tests/snapshots/ninja/ninja_snapshot_tests__console_manifest_ninja.snap b/tests/snapshots/ninja/ninja_snapshot_tests__console_manifest_ninja.snap index e6778906b..8b7097460 100644 --- a/tests/snapshots/ninja/ninja_snapshot_tests__console_manifest_ninja.snap +++ b/tests/snapshots/ninja/ninja_snapshot_tests__console_manifest_ninja.snap @@ -15,6 +15,7 @@ rule aed5f83a13f8763315f10af13630dd88eccbc70c46a8f0a4c2ac132e7f6f2858 command = echo ordinary-action build inline-console: 04ca8490eedc2a14f7ea86f319edd5b7ea47a9b1dc3deab48f610cfba9a50fe0 + description = Building ⁨inline-console⁩ pool = console build ordinary-action: aed5f83a13f8763315f10af13630dd88eccbc70c46a8f0a4c2ac132e7f6f2858 diff --git a/tests/ui/build_edge_console_compile_pass.rs b/tests/ui/build_edge_console_compile_pass.rs index 90bdb2972..470fca50b 100644 --- a/tests/ui/build_edge_console_compile_pass.rs +++ b/tests/ui/build_edge_console_compile_pass.rs @@ -17,6 +17,7 @@ pub fn console_edge() -> BuildEdge { explicit_outputs: vec!["lint".into()], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), + progress_label: None, phony: false, always: false, console: true, From 86434d18b7ed4b84f949a4bd8dbf374f5cd99e88 Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 30 Oct 2026 17:56:57 +0100 Subject: [PATCH 13/27] Document console's BuildEdge break and beta4 gap (#946) Adding `console` to `BuildEdge` breaks downstream struct literals because the struct exposes public fields and is not `#[non_exhaustive]`. Name `BuildEdge` alongside `Target` in ADR-0061 and in the migration guide's table, prose, and checklist, so downstream callers see every affected type. The users' guide described beta4 while beta4 rejects `console` during manifest validation. State that the field arrives in v0.1.0-beta5 and how to obtain a compatible build. Co-Authored-By: Claude Code --- ...1-select-ninja-console-pool-per-build-edge.md | 12 +++++++----- docs/users-guide.md | 5 +++++ docs/v0-1-0-migration-guide.md | 16 ++++++++++------ 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md b/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md index 0ad5ccb18..e1c9252d2 100644 --- a/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md +++ b/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md @@ -50,11 +50,13 @@ owner. - Validate these rules before both direct Ninja generation and dyndep-bundle generation, preserving the existing missing-action diagnostic path. -Adding the field to the public `Target` struct breaks downstream Rust callers -that construct it with a struct literal. The package currently declares version -`0.1.0-beta4`, and this source break is accepted for the next pre-release API: -callers must provide `console`. The default only preserves manifest -compatibility through Serde. +Adding the field to the public `Target` and `BuildEdge` structs breaks +downstream Rust callers that construct either type with a struct literal. Both +structs expose public fields and neither is `#[non_exhaustive]`, so an existing +literal fails to compile until it names `console`. The package currently +declares version `0.1.0-beta4`, and this source break is accepted for the next +pre-release API: callers must provide `console`. The default only preserves +manifest compatibility through Serde. Ninja runs console-pool edges one at a time in one invocation. Output from other edges is held while a console edge runs. Netsuke forwards Ninja output as diff --git a/docs/users-guide.md b/docs/users-guide.md index 86da35812..9368acc2d 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -624,6 +624,11 @@ list of strings. ### Stream long-running output with the console pool +The `console` field ships in the next pre-release, `v0.1.0-beta5`. The published +`v0.1.0-beta4` release rejects it during manifest validation, so install a +beta5-or-later release, or build from a checkout of the `main` branch, before +relying on it. + Set `console: true` on a long-running or interactive action or target, such as `lint` or `test`, when its output should appear as it runs: diff --git a/docs/v0-1-0-migration-guide.md b/docs/v0-1-0-migration-guide.md index d07ab6523..0cf980d72 100644 --- a/docs/v0-1-0-migration-guide.md +++ b/docs/v0-1-0-migration-guide.md @@ -14,7 +14,7 @@ sections that match the features they use. | Required | Checkout source builds | Use the pinned Rust toolchain before building from a checkout. | | Required | Linux checkout builds | Install the pinned `mold` linker before building from a checkout. | | Review if enabled | Manifest scheduling | Opt in only where immediate console output is useful. | -| Required | Rust `Target` literals | Set the `description` and `console` fields explicitly. | +| Required | `Target`, `BuildEdge` | Set `description` and `console` on the struct literals you build. | | Review if enabled | Existing pool choices | Avoid dependency-only entries and conflicting action pools. | | Optional | Existing manifests | Omit `console` to retain ordinary scheduling; it defaults to false. | @@ -62,10 +62,13 @@ their input boundary. Rust callers that construct `Target` with a struct literal must add the new `description` field (set it to `None` or `Some(...)`) and the `console` field -(set it to `false` or `true`); deserialized manifests remain compatible because -`console` defaults to `false`. Callers constructing `NinjaBuildRequest` or -`NinjaToolRequest` must replace `cli: &cli` with `options: &options`. Callers -that read `BuildGraph::targets` must use the graph accessors instead; see +(set it to `false` or `true`). Callers that construct `BuildEdge` with a struct +literal must add `console` for the same reason: both types expose public fields +and neither is `#[non_exhaustive]`. Deserialized manifests remain compatible +because `console` defaults to `false`. Callers constructing `NinjaBuildRequest` +or `NinjaToolRequest` must replace `cli: &cli` with `options: &options`. +Callers that read `BuildGraph::targets` must use the graph accessors instead; +see [Resolve build-graph outputs through the accessors](#resolve-build-graph-outputs-through-the-accessors). Every other addition is opt-in. @@ -702,7 +705,8 @@ owned sink and completion-ordering contract. - [ ] Review the migration sections for manifest or API features in use. - [ ] Before opting into `console`, use Ninja 1.5 or later and check that the action does not request a conflicting custom pool or contention class. -- [ ] Add `console: false` or `console: true` to Rust `Target` struct literals. +- [ ] Add `console: false` or `console: true` to Rust `Target` and `BuildEdge` + struct literals. ## Common errors and fixes From bfc0ba761a938ed0c382fadf6993966f6d1eb9ab Mon Sep 17 00:00:00 2001 From: leynos Date: Fri, 30 Oct 2026 21:00:50 +0100 Subject: [PATCH 14/27] Split migration obligations for Target and BuildEdge (#946) The consolidated migration row told callers to add `description` and `console` on "both Rust struct literals", but `description` exists only on `Target`. `BuildEdge` gains `console` alone. Give each type its own Required row so the checklist never asks for a field the type does not have. Co-Authored-By: Claude Code --- docs/v0-1-0-migration-guide.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/v0-1-0-migration-guide.md b/docs/v0-1-0-migration-guide.md index 0cf980d72..8b489c9b0 100644 --- a/docs/v0-1-0-migration-guide.md +++ b/docs/v0-1-0-migration-guide.md @@ -14,7 +14,8 @@ sections that match the features they use. | Required | Checkout source builds | Use the pinned Rust toolchain before building from a checkout. | | Required | Linux checkout builds | Install the pinned `mold` linker before building from a checkout. | | Review if enabled | Manifest scheduling | Opt in only where immediate console output is useful. | -| Required | `Target`, `BuildEdge` | Set `description` and `console` on the struct literals you build. | +| Required | `Target` literals | Add `description` and `console` to every literal. | +| Required | `BuildEdge` literals | Add `console` to every literal. | | Review if enabled | Existing pool choices | Avoid dependency-only entries and conflicting action pools. | | Optional | Existing manifests | Omit `console` to retain ordinary scheduling; it defaults to false. | From 87013f096df6715e6d65906ee9e84304d7ca0140 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 31 Oct 2026 05:25:27 +0100 Subject: [PATCH 15/27] Add console field to rebased determinism fixtures (#946) Rebasing onto main brought in the determinism property-test and strategy modules added by #700, which construct `Target` and `BuildEdge` literals that predate the new `console` field. Add `console: false` to each so the workspace type-checks now that both structs require the field. `console` is false throughout: these generators and fixtures model parallel/serial ordering and output aliasing, not console scheduling, so the default preserves their intent. `git diff` against the pre-rebase branch shows the twelve sites are all new-in-target code. Co-Authored-By: Claude Code --- src/ir/graph/property_tests/cycles.rs | 1 + src/ir/graph/property_tests/duplicates.rs | 1 + src/ninja_gen/determinism/property_tests/declaration.rs | 1 + .../determinism/property_tests/defaults/generator.rs | 1 + .../property_tests/ninja_oracle/relay_regression.rs | 4 ++++ src/ninja_gen/determinism/property_tests/no_loss.rs | 1 + src/ninja_gen/determinism/property_tests/order.rs | 1 + src/ninja_gen/determinism/strategy/graph/blueprint.rs | 1 + src/ninja_gen/determinism/strategy/manifest/mod.rs | 1 + 9 files changed, 12 insertions(+) diff --git a/src/ir/graph/property_tests/cycles.rs b/src/ir/graph/property_tests/cycles.rs index cc8223358..3a5edb605 100644 --- a/src/ir/graph/property_tests/cycles.rs +++ b/src/ir/graph/property_tests/cycles.rs @@ -287,6 +287,7 @@ fn target(slot: usize, deps: &[usize]) -> Target { vars: Vars::default(), phony: false, always: false, + console: false, conditional: false, description: None, } diff --git a/src/ir/graph/property_tests/duplicates.rs b/src/ir/graph/property_tests/duplicates.rs index 64b2f3753..c9f07af12 100644 --- a/src/ir/graph/property_tests/duplicates.rs +++ b/src/ir/graph/property_tests/duplicates.rs @@ -247,6 +247,7 @@ fn target(slot: usize, outputs: Vec) -> Target { vars: Vars::default(), phony: false, always: false, + console: false, conditional: false, description: None, } diff --git a/src/ninja_gen/determinism/property_tests/declaration.rs b/src/ninja_gen/determinism/property_tests/declaration.rs index 3c3147f56..e173b170e 100644 --- a/src/ninja_gen/determinism/property_tests/declaration.rs +++ b/src/ninja_gen/determinism/property_tests/declaration.rs @@ -354,6 +354,7 @@ fn rule_backed_target(name: &str) -> Target { vars: Vars::default(), phony: false, always: false, + console: false, conditional: false, description: None, } diff --git a/src/ninja_gen/determinism/property_tests/defaults/generator.rs b/src/ninja_gen/determinism/property_tests/defaults/generator.rs index 7e3027165..26230ccc8 100644 --- a/src/ninja_gen/determinism/property_tests/defaults/generator.rs +++ b/src/ninja_gen/determinism/property_tests/defaults/generator.rs @@ -172,6 +172,7 @@ pub(super) fn edge(index: usize) -> BuildEdge { progress_label: None, phony: false, always: false, + console: false, } } diff --git a/src/ninja_gen/determinism/property_tests/ninja_oracle/relay_regression.rs b/src/ninja_gen/determinism/property_tests/ninja_oracle/relay_regression.rs index 73938b738..37af37c2e 100644 --- a/src/ninja_gen/determinism/property_tests/ninja_oracle/relay_regression.rs +++ b/src/ninja_gen/determinism/property_tests/ninja_oracle/relay_regression.rs @@ -110,6 +110,7 @@ fn crash_shaped_graph() -> BuildGraph { progress_label: None, phony: false, always: false, + console: false, }, BuildEdge { action_id: "aggregate".into(), @@ -122,6 +123,7 @@ fn crash_shaped_graph() -> BuildGraph { progress_label: None, phony: true, always: false, + console: false, }, BuildEdge { action_id: "aggregate".into(), @@ -134,6 +136,7 @@ fn crash_shaped_graph() -> BuildGraph { progress_label: None, phony: true, always: false, + console: false, }, BuildEdge { action_id: "aggregate".into(), @@ -146,6 +149,7 @@ fn crash_shaped_graph() -> BuildGraph { progress_label: None, phony: true, always: false, + console: false, }, ] { // A duplicate alias would silently change the graph this test exists to diff --git a/src/ninja_gen/determinism/property_tests/no_loss.rs b/src/ninja_gen/determinism/property_tests/no_loss.rs index 2789ac298..2408a3b6a 100644 --- a/src/ninja_gen/determinism/property_tests/no_loss.rs +++ b/src/ninja_gen/determinism/property_tests/no_loss.rs @@ -191,6 +191,7 @@ fn edge_with_explicit_outputs(outputs: &[&str]) -> BuildEdge { progress_label: None, phony: false, always: false, + console: false, } } diff --git a/src/ninja_gen/determinism/property_tests/order.rs b/src/ninja_gen/determinism/property_tests/order.rs index 870342d5a..b650d117a 100644 --- a/src/ninja_gen/determinism/property_tests/order.rs +++ b/src/ninja_gen/determinism/property_tests/order.rs @@ -328,6 +328,7 @@ fn the_edge_rule_keys_on_the_output_set() { progress_label: None, phony: false, always: false, + console: false, }; let later = edge(["out/a", "out/z"]); let earlier = edge(["out/a", "out/m"]); diff --git a/src/ninja_gen/determinism/strategy/graph/blueprint.rs b/src/ninja_gen/determinism/strategy/graph/blueprint.rs index 41c800d86..f6d010d0a 100644 --- a/src/ninja_gen/determinism/strategy/graph/blueprint.rs +++ b/src/ninja_gen/determinism/strategy/graph/blueprint.rs @@ -149,6 +149,7 @@ impl Blueprint { progress_label: None, phony: plan.is_phony, always: plan.is_always, + console: false, }); } edges diff --git a/src/ninja_gen/determinism/strategy/manifest/mod.rs b/src/ninja_gen/determinism/strategy/manifest/mod.rs index 9aae510aa..9dc38deb2 100644 --- a/src/ninja_gen/determinism/strategy/manifest/mod.rs +++ b/src/ninja_gen/determinism/strategy/manifest/mod.rs @@ -185,6 +185,7 @@ impl TargetPlan { vars: Vars::default(), phony: false, always: false, + console: false, conditional: false, description: None, } From 77ca8c4302285c19bc441dcb8e2d751b3cbd3ca8 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 31 Oct 2026 07:06:51 +0100 Subject: [PATCH 16/27] Split the edge strategy into its own property-test module (#946) Rebasing onto main left `src/ninja_gen/property_tests/mod.rs` at 403 raw lines, three over Whitaker's 400-line module cap: main's determinism work had already grown it to 399, and the console property-test module and the new `console`/`action_pool` fields crossed the limit. Move `edge_strategy_with_ranges` into `property_tests/edge_strategy.rs`, where it is the module's single responsibility, and re-export it to the two separator properties that consume it. The parent module drops to 366 lines, leaving headroom for future property tests. Co-Authored-By: Claude Code --- src/ninja_gen/property_tests/edge_strategy.rs | 50 +++++++++++++++++++ src/ninja_gen/property_tests/mod.rs | 41 +-------------- 2 files changed, 52 insertions(+), 39 deletions(-) create mode 100644 src/ninja_gen/property_tests/edge_strategy.rs diff --git a/src/ninja_gen/property_tests/edge_strategy.rs b/src/ninja_gen/property_tests/edge_strategy.rs new file mode 100644 index 000000000..a5a0dcce8 --- /dev/null +++ b/src/ninja_gen/property_tests/edge_strategy.rs @@ -0,0 +1,50 @@ +//! Proptest strategy for generated Ninja build edges. +//! +//! The separator properties in the parent module generate edges with varied +//! input, implicit, and order-only dependency counts. Generating them here +//! keeps the parent module inside the repository's module-size limit. + +use proptest::prelude::*; +use test_support::ninja_gen::paths_strategy; + +use crate::ir::BuildEdge; + +/// Build a strategy for edges drawing dependency paths from the given ranges. +pub(super) fn edge_strategy_with_ranges( + input_range: std::ops::Range, + implicit_range: std::ops::Range, + order_only_range: std::ops::Range, +) -> impl Strategy { + ( + "[a-z][a-z0-9_]{0,8}", + paths_strategy("in", input_range), + paths_strategy("out", 1..5), + paths_strategy("iout", 0..5), + paths_strategy("imp", implicit_range), + paths_strategy("order", order_only_range), + ) + .prop_map( + |( + action_id, + inputs, + explicit_outputs, + implicit_outputs, + implicit_deps, + order_only_deps, + )| { + BuildEdge { + action_id, + inputs, + implicit_deps, + dependency_order: crate::ir::DependencyOrder::Parallel, + explicit_outputs, + implicit_outputs, + order_only_deps, + progress_label: None, + phony: false, + always: false, + console: false, + } + }, + ) +} diff --git a/src/ninja_gen/property_tests/mod.rs b/src/ninja_gen/property_tests/mod.rs index f368d5f7b..46fbdf639 100644 --- a/src/ninja_gen/property_tests/mod.rs +++ b/src/ninja_gen/property_tests/mod.rs @@ -20,49 +20,12 @@ use std::collections::{HashMap, HashSet}; mod console; mod dependency_only; +mod edge_strategy; mod ninja_oracle; +use edge_strategy::edge_strategy_with_ranges; use ninja_oracle::scalar_graph; -fn edge_strategy_with_ranges( - input_range: std::ops::Range, - implicit_range: std::ops::Range, - order_only_range: std::ops::Range, -) -> impl Strategy { - ( - "[a-z][a-z0-9_]{0,8}", - paths_strategy("in", input_range), - paths_strategy("out", 1..5), - paths_strategy("iout", 0..5), - paths_strategy("imp", implicit_range), - paths_strategy("order", order_only_range), - ) - .prop_map( - |( - action_id, - inputs, - explicit_outputs, - implicit_outputs, - implicit_deps, - order_only_deps, - )| { - BuildEdge { - action_id, - inputs, - implicit_deps, - dependency_order: crate::ir::DependencyOrder::Parallel, - explicit_outputs, - implicit_outputs, - order_only_deps, - progress_label: None, - phony: false, - always: false, - console: false, - } - }, - ) -} - fn format_edge(edge: &BuildEdge) -> String { DisplayEdge { edge, From 877878f50d24cb09fd6105fe65fc6e4fd8ee8387 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 31 Oct 2026 07:36:14 +0100 Subject: [PATCH 17/27] Adapt console serial test to relay staging (#946) Main's dyndep staging now emits a relay phony alongside every synthetic serial gate, so `build .netsuke/serial/` matches four statements for a two-dependency serial edge instead of two. Collect the serial statements once and assert the two shapes separately: two gate statements carrying the order-only separator, and two relay phonies. The console assertions keep their original force: the user edge selects the console pool, no synthetic statement does, and no sidecar carries console metadata. Co-Authored-By: Claude Code --- src/ninja_gen/dyndep/tests/console.rs | 20 +++++++++++++++++--- 1 file changed, 17 insertions(+), 3 deletions(-) diff --git a/src/ninja_gen/dyndep/tests/console.rs b/src/ninja_gen/dyndep/tests/console.rs index 37d25ecd1..194775fde 100644 --- a/src/ninja_gen/dyndep/tests/console.rs +++ b/src/ninja_gen/dyndep/tests/console.rs @@ -5,7 +5,8 @@ use crate::ir::{BuildEdge, DependencyOrder}; use anyhow::{Result, ensure}; use camino::Utf8PathBuf; -/// Keep console scheduling on the user edge, away from synthetic gates and sidecars. +/// Keep console scheduling on the user edge, away from synthetic relays, gates, +/// and sidecars. #[test] fn console_serial_parent_does_not_mark_gates_or_sidecars() -> Result<()> { let mut edge = serial_edge("all", &["check-fmt", "lint"]); @@ -35,13 +36,26 @@ fn console_serial_parent_does_not_mark_gates_or_sidecars() -> Result<()> { == 1, "synthetic gate edges must not select the console pool:\n{file}" ); + let serial_statements: Vec<&str> = file + .lines() + .filter(|line| line.starts_with("build .netsuke/serial/")) + .collect(); ensure!( - file.lines() - .filter(|line| line.starts_with("build .netsuke/serial/")) + serial_statements + .iter() + .filter(|line| line.contains(": phony || ")) .count() == 2, "expected two synthetic serial gates:\n{file}" ); + ensure!( + serial_statements + .iter() + .filter(|line| line.contains("/relay-")) + .count() + == 2, + "expected one relay phony per serial gate:\n{file}" + ); ensure!( bundle .dyndep_files() From ab9b5ecbcf98b7ac368a00ea2c602fbc21e740ac Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 31 Oct 2026 09:58:48 +0100 Subject: [PATCH 18/27] Match relay names without the path separator (#946) The serial-staging test identified relay statements by looking for "/relay-". Staging writes the literal ".netsuke/serial/" prefix but joins the generated components with the platform separator, so on Windows the rendered statement reads "...\relay-000" and the forward-slash probe matched nothing. The Windows CI job failed on that count. Match the relay name fragment instead and require the relay shape, which is a single implicit-dependency pipe rather than the gate's order-only separator. The assertion still pins one relay per serial gate without depending on how a platform renders the staging path. Co-Authored-By: Claude Code --- src/ninja_gen/dyndep/tests/console.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/ninja_gen/dyndep/tests/console.rs b/src/ninja_gen/dyndep/tests/console.rs index 194775fde..cc7ed4564 100644 --- a/src/ninja_gen/dyndep/tests/console.rs +++ b/src/ninja_gen/dyndep/tests/console.rs @@ -48,10 +48,13 @@ fn console_serial_parent_does_not_mark_gates_or_sidecars() -> Result<()> { == 2, "expected two synthetic serial gates:\n{file}" ); + // Match the relay name without its leading separator: staging writes the + // literal `.netsuke/serial/` prefix, but joins the generated components + // with the platform separator, which is `\` on Windows. ensure!( serial_statements .iter() - .filter(|line| line.contains("/relay-")) + .filter(|line| line.contains("relay-") && line.contains(": phony | ")) .count() == 2, "expected one relay phony per serial gate:\n{file}" From d5614dfad39b69a370ced61bd2c1e1abc991e0f0 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 31 Oct 2026 18:05:42 +0100 Subject: [PATCH 19/27] Verify exact console pool bindings in property tests Assert the exact `pool` binding of every emitted edge, synthetic gate, and dyndep sidecar instead of only counting console edges. The property now covers an action whose shared rule block already selects the console pool, where a non-console edge must emit an empty `pool =` binding to clear the inherited value. `test_support::ninja_edge::edge_pool_bindings` exposes the per-edge bindings so the property and any future caller assert the same rendered form. File-wide console bindings are checked as one binding per console edge plus the single rule-level binding, and sidecars must carry no pool metadata at all. The generated selection list always leads with one console and one non-console sibling, so a case cannot pass by exercising a single state. Co-Authored-By: Claude Code --- .../ninja_gen/property_tests/console.txt | 7 + src/ninja_gen/property_tests/console.rs | 207 ++++++++++++++---- test_support/src/ninja_edge.rs | 73 +++++- 3 files changed, 237 insertions(+), 50 deletions(-) create mode 100644 proptest-regressions/ninja_gen/property_tests/console.txt diff --git a/proptest-regressions/ninja_gen/property_tests/console.txt b/proptest-regressions/ninja_gen/property_tests/console.txt new file mode 100644 index 000000000..3a41c5aeb --- /dev/null +++ b/proptest-regressions/ninja_gen/property_tests/console.txt @@ -0,0 +1,7 @@ +# Seeds for failure cases proptest has generated in the past. It is +# automatically read and these particular cases re-run before any +# novel cases are generated. +# +# It is recommended to check this file in to source control so that +# everyone who runs the test benefits from these saved cases. +cc 7e26a15f98f3494221509552e080ff20866d56aff6843f173095b3d96fc51b27 # shrinks to selections = [false, false], parent_console = false diff --git a/src/ninja_gen/property_tests/console.rs b/src/ninja_gen/property_tests/console.rs index 3498dcf8f..46b34ec82 100644 --- a/src/ninja_gen/property_tests/console.rs +++ b/src/ninja_gen/property_tests/console.rs @@ -1,62 +1,173 @@ //! Check console scheduling across generated user and serial edges. +//! +//! Each property generates a serial parent edge plus parallel sibling edges +//! and inspects the exact `pool` binding of every emitted statement. Coverage +//! includes the inherited case where the shared action already selects the +//! console pool, so a generated edge must clear it rather than silently +//! inherit console scheduling. -use super::{generate_bundle, serial_graph}; -use crate::ir::DependencyOrder; +use super::{aggregate_gates, generate_bundle, serial_graph}; +use crate::ir::{BuildGraph, DependencyOrder}; use camino::{Utf8Path, Utf8PathBuf}; use proptest::prelude::*; -use test_support::ninja_edge::edge_has_console_pool; +use test_support::ninja_edge::edge_pool_bindings; -proptest! { - #![proptest_config(ProptestConfig { cases: 64, .. ProptestConfig::default() })] +/// Return the exact edge-local bindings a console state must emit. +/// +/// A console edge selects the pool, an edge that inherits a console action +/// pool without opting in clears it with an empty binding, and an edge whose +/// action carries no pool emits nothing at all. +fn expected_pool_bindings(console: bool, inherits_console_pool: bool) -> Vec { + match (console, inherits_console_pool) { + (true, _) => vec!["pool = console".to_owned()], + (false, true) => vec!["pool =".to_owned()], + (false, false) => Vec::new(), + } +} - #[test] - fn console_bindings_match_user_edges( - selections in prop::collection::vec(any::(), 2..7), - parent_console in any::(), - ) { - let outputs: Vec<_> = (0..selections.len()) - .map(|index| Utf8PathBuf::from(format!("dep{index}"))) - .collect(); - let mut graph = serial_graph(outputs.clone()); - let mut parent = graph - .target_for_output(Utf8Path::new("aggregate")) - .map(|(_, edge)| edge.clone()) - .ok_or_else(|| TestCaseError::fail("serial parent must exist"))?; - parent.console = parent_console; - prop_assert!(graph.replace_edge_for_output(Utf8Path::new("aggregate"), parent.clone())); - - for (output, selected) in outputs.iter().zip(&selections) { - let mut edge = parent.clone(); - edge.explicit_outputs = vec![output.clone()]; - edge.implicit_deps.clear(); - edge.dependency_order = DependencyOrder::Parallel; - edge.console = *selected; - prop_assert!(graph.insert_edge(edge).is_ok()); - } - - let bundle = generate_bundle(&graph) +/// Build the serial fixture, optionally inheriting a console action pool. +fn console_graph( + dependencies: Vec, + inherits_console_pool: bool, +) -> Result { + let mut graph = serial_graph(dependencies); + if inherits_console_pool { + graph + .actions + .get_mut("a") + .ok_or_else(|| TestCaseError::fail("fixture action must exist"))? + .pool = Some("console".into()); + } + Ok(graph) +} + +/// Generate a bundle whose parent and sibling edges share one action. +fn generate_console_bundle( + selections: &[bool], + parent_console: bool, + inherits_console_pool: bool, +) -> Result { + let outputs: Vec<_> = (0..selections.len()) + .map(|index| Utf8PathBuf::from(format!("dep{index}"))) + .collect(); + let mut graph = console_graph(outputs.clone(), inherits_console_pool)?; + let mut parent = graph + .target_for_output(Utf8Path::new("aggregate")) + .map(|(_, edge)| edge.clone()) + .ok_or_else(|| TestCaseError::fail("serial parent must exist"))?; + parent.console = parent_console; + if !graph.replace_edge_for_output(Utf8Path::new("aggregate"), parent.clone()) { + return Err(TestCaseError::fail("serial parent must be replaceable")); + } + + for (output, selected) in outputs.iter().zip(selections) { + let mut edge = parent.clone(); + edge.explicit_outputs = vec![output.clone()]; + edge.implicit_deps.clear(); + edge.dependency_order = DependencyOrder::Parallel; + edge.console = *selected; + graph + .insert_edge(edge) .map_err(|error| TestCaseError::fail(error.to_string()))?; - let file = bundle.build_file(); - for (output, selected) in outputs.iter().zip(&selections) { - let actual = edge_has_console_pool(file, output.as_str()) - .map_err(|error| TestCaseError::fail(error.to_string()))?; - prop_assert_eq!(actual, *selected); - } - let parent_actual = edge_has_console_pool(file, "aggregate") + } + + generate_bundle(&graph).map_err(|error| TestCaseError::fail(error.to_string())) +} + +/// Assert every user, synthetic gate, and sidecar binding for one case. +fn assert_console_bindings( + bundle: &super::GeneratedNinja, + selections: &[bool], + parent_console: bool, + inherits_console_pool: bool, +) -> Result<(), TestCaseError> { + let file = bundle.build_file(); + let mut selected_count = usize::from(parent_console); + + for (index, selected) in selections.iter().enumerate() { + let output = format!("dep{index}"); + let bindings = edge_pool_bindings(file, &output) .map_err(|error| TestCaseError::fail(error.to_string()))?; - prop_assert_eq!(parent_actual, parent_console); - let selected_count = selections.iter().filter(|selected| **selected).count() - + usize::from(parent_console); - prop_assert_eq!(bundle.console_edge_count(), selected_count); prop_assert_eq!( - file.lines().filter(|line| *line == " pool = console").count(), - selected_count + &bindings, + &expected_pool_bindings(*selected, inherits_console_pool), + "edge {} must carry exactly its own console binding", + output ); + selected_count += usize::from(*selected); + } + + let parent_bindings = edge_pool_bindings(file, "aggregate") + .map_err(|error| TestCaseError::fail(error.to_string()))?; + prop_assert_eq!( + &parent_bindings, + &expected_pool_bindings(parent_console, inherits_console_pool), + "the serial parent must carry exactly its own console binding" + ); + + let gates = + aggregate_gates(file).ok_or_else(|| TestCaseError::fail("aggregate gate list missing"))?; + for gate in gates { + let bindings = edge_pool_bindings(file, gate) + .map_err(|error| TestCaseError::fail(error.to_string()))?; prop_assert!( - bundle - .dyndep_files() - .iter() - .all(|sidecar| !sidecar.content().contains("pool = console")) + bindings.is_empty(), + "synthetic gate {gate} must carry no pool binding: {bindings:?}" ); } + + prop_assert_eq!(bundle.console_edge_count(), selected_count); + // A console action also renders one `pool = console` binding in its shared + // rule block, so the whole file holds one binding per console edge plus + // that single rule binding. + let rule_bindings = usize::from(inherits_console_pool); + prop_assert_eq!( + file.lines() + .filter(|line| *line == " pool = console") + .count(), + selected_count + rule_bindings, + "console bindings must be one per console edge plus the shared rule" + ); + prop_assert!( + bundle + .dyndep_files() + .iter() + .all(|sidecar| !sidecar.content().contains("pool")) + ); + Ok(()) +} + +/// Generate sibling console selections that always cover both states. +/// +/// Two fixed states lead the list so every case pairs a console sibling with a +/// non-console one, which is what distinguishes a selected binding from a +/// cleared one. The rest of the list stays arbitrary. +fn console_selections() -> impl Strategy> { + prop::collection::vec(any::(), 0..5).prop_map(|mut tail| { + tail.insert(0, true); + tail.insert(1, false); + tail + }) +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 64, .. ProptestConfig::default() })] + + #[test] + fn console_bindings_match_user_edges( + selections in console_selections(), + parent_console in any::(), + ) { + let bundle = generate_console_bundle(&selections, parent_console, false)?; + assert_console_bindings(&bundle, &selections, parent_console, false)?; + } + + #[test] + fn inherited_console_pool_is_cleared_on_non_console_edges( + selections in console_selections(), + parent_console in any::(), + ) { + let bundle = generate_console_bundle(&selections, parent_console, true)?; + assert_console_bindings(&bundle, &selections, parent_console, true)?; + } } diff --git a/test_support/src/ninja_edge.rs b/test_support/src/ninja_edge.rs index 6b70de56f..38d5b97f3 100644 --- a/test_support/src/ninja_edge.rs +++ b/test_support/src/ninja_edge.rs @@ -26,6 +26,46 @@ use anyhow::{Context, Result}; /// /// Returns an error when the named build edge is absent. pub fn edge_has_console_pool(ninja: &str, output: &str) -> Result { + Ok(edge_bindings(ninja, output)? + .into_iter() + .any(|line| line.trim() == "pool = console")) +} + +/// Return every edge-local `pool` binding attached to the named build edge. +/// +/// A console edge carries `pool = console`, an edge that inherits a console +/// action pool but does not opt in carries the empty `pool =` clearing +/// binding, and an edge with no console inheritance carries none. Returning +/// the bindings lets a test distinguish those three states instead of +/// reducing them to a Boolean. +/// +/// # Examples +/// +/// ``` +/// use test_support::ninja_edge::edge_pool_bindings; +/// +/// let ninja = "build ordinary: command\n pool =\n"; +/// assert_eq!(edge_pool_bindings(ninja, "ordinary")?, vec!["pool =".to_owned()]); +/// # Ok::<(), anyhow::Error>(()) +/// ``` +/// +/// # Errors +/// +/// Returns an error when the named build edge is absent. +pub fn edge_pool_bindings(ninja: &str, output: &str) -> Result> { + Ok(edge_bindings(ninja, output)? + .into_iter() + .filter(|line| line.trim_start().starts_with("pool =")) + .map(|line| line.trim().to_owned()) + .collect()) +} + +/// Return the indented binding lines that follow the named build statement. +/// +/// Ninja ends an edge at the first line that is not indented, so taking the +/// contiguous indented run scopes every lookup to one build statement and +/// keeps a neighbouring edge's binding from satisfying an assertion. +fn edge_bindings<'a>(ninja: &'a str, output: &str) -> Result> { let lines: Vec<_> = ninja.lines().collect(); let edge_index = lines .iter() @@ -36,7 +76,8 @@ pub fn edge_has_console_pool(ninja: &str, output: &str) -> Result { .iter() .skip(edge_index + 1) .take_while(|line| line.starts_with(" ")) - .any(|line| *line == " pool = console")) + .copied() + .collect()) } /// Return whether a generated build statement names `output` in its output list. @@ -86,7 +127,7 @@ fn ninja_output_tokens(outputs: &str) -> Vec { mod tests { //! Cover output-list matching for generated Ninja build statements. - use super::edge_has_console_pool; + use super::{edge_has_console_pool, edge_pool_bindings}; use anyhow::{Result, ensure}; #[test] @@ -112,4 +153,32 @@ mod tests { assert!(edge_has_console_pool(ninja, "target").is_err()); } + + #[test] + fn pool_bindings_distinguish_selected_cleared_and_absent_states() -> Result<()> { + let ninja = concat!( + "build selected: command\n pool = console\n\n", + "build cleared: command\n pool =\n\n", + "build absent: command\n command = touch absent\n", + ); + + ensure!(edge_pool_bindings(ninja, "selected")? == vec!["pool = console".to_owned()]); + ensure!(edge_pool_bindings(ninja, "cleared")? == vec!["pool =".to_owned()]); + ensure!(edge_pool_bindings(ninja, "absent")?.is_empty()); + Ok(()) + } + + #[test] + fn pool_bindings_are_scoped_to_the_named_edge() -> Result<()> { + let ninja = "build first: command\n pool = console\n\nbuild second: command\n"; + + ensure!(edge_pool_bindings(ninja, "first")? == vec!["pool = console".to_owned()]); + ensure!(edge_pool_bindings(ninja, "second")?.is_empty()); + Ok(()) + } + + #[test] + fn pool_bindings_report_a_missing_edge() { + assert!(edge_pool_bindings("build present: command\n", "absent").is_err()); + } } From 2a99283eeca60102f7b9f819096e5326221565b7 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 31 Oct 2026 18:07:15 +0100 Subject: [PATCH 20/27] Classify child-output forwarding failures Record a closed `stream`/`failure_kind` vocabulary for every child-output forwarding failure, count the classified series through the application recorder, and state the build-result policy explicitly. Read, write, flush, and drain failures are now distinct: `ForwardStats` carries the classification, and `forward_failure` owns the bounded label arrays, the counter description, and the warning event. A drain failure is counted separately from the failure that triggered the drain. Interrupted reads stay retries rather than terminal failures, and a flush failure never writes the accepted bytes again. The application recorder admits the counter only through `FORWARD_FAILURE_LABELS`, so an unregistered metric name or a widened label set becomes a noop series instead of reaching the retained snapshot. This delivers the bounded telemetry required by issue #1048. Forwarding loss never changes the reported build result: the child's exit status stays authoritative, and a closed parent pipe (`netsuke build | head`) cannot fail the build. `finalize_streaming` documents that policy, and the process tests cover a failing child, a successful child, and a joined stderr stream. The developers' guide records the reuse boundary, the classification and telemetry contract, and the result policy. Co-Authored-By: Claude Code --- docs/developers-guide.md | 56 ++- src/observability/recorder/mod.rs | 5 +- .../recorder/tests/forwarding_tests.rs | 114 ++++++ src/observability/recorder/tests/mod.rs | 3 + src/runner/mod.rs | 6 +- src/runner/process/child_exit.rs | 22 +- src/runner/process/failure_attribution.rs | 4 +- src/runner/process/mod.rs | 4 +- src/runner/process/output_forwarding.rs | 16 +- .../process/streaming/forward_failure.rs | 157 ++++++++ src/runner/process/streaming/mod.rs | 106 ++++-- .../process/streaming/telemetry_tests.rs | 6 +- src/runner/process/streaming/tests.rs | 31 +- .../process/streaming/tests/failure_tests.rs | 334 ++++++++++++++++++ .../process/streaming/tests/flush_tests.rs | 10 +- src/runner/process/tests/mod.rs | 74 +++- 16 files changed, 866 insertions(+), 82 deletions(-) create mode 100644 src/observability/recorder/tests/forwarding_tests.rs create mode 100644 src/runner/process/streaming/forward_failure.rs create mode 100644 src/runner/process/streaming/tests/failure_tests.rs diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 315108383..e73963408 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -10198,7 +10198,9 @@ success envelope; diagnostic serialization remains in generator metadata from the private `src/json_envelope.rs` module. Within process execution, `forward_stdout` is the single composition point for choosing status-aware or plain child-output draining, and its callers select -either the terminal or a JSON-mode sink. +either the terminal or a JSON-mode sink. The copy loop itself, and the +forwarding-failure vocabulary around it, are owned by +[`runner::process::streaming`](#module-runnerprocessstreaming). `ExecutionContext` is the private dispatch context shared by build and clean handlers. `run_with_ninja_program` constructs it after resolving output mode @@ -10437,6 +10439,58 @@ measuring the parent test process. It compares progress parsing with `--progress never` and permits a fixed 16 MiB overhead, rejecting memory growth proportional to the payload. +### Module: `runner::process::streaming` + +`src/runner/process/streaming/` owns the only byte-for-byte copy loop between a +child's standard streams and a forwarding writer, the bounded Ninja +status-parsing adapter, and the closed forwarding-failure vocabulary in +`forward_failure.rs`. No other module may add a second copy loop. + +#### Streaming reuse boundary + +- **Ownership:** the streaming module owns chunk reads, per-chunk writes and + flushes, interrupted-read retries, draining after a failure, the + `ForwardStats` record, and the failure classification that telemetry + publishes. +- **Permitted call-sites:** exactly two. `output_forwarding::forward_stdout` + forwards stdout, choosing `forward_child_output_with_ninja_status` when + progress updates are wanted and `forward_child_output` otherwise. + `failure_attribution::forward_stderr_with_attribution` forwards stderr + through its marker-observing writer. Both pass the stream identity as + `ForwardStream`; no caller may invent a stream label. +- **Composition rules:** flush each chunk before reading the next one, so a + flush failure cannot cause already-accepted bytes to be written again. Retry + `io::ErrorKind::Interrupted` reads rather than reporting them. Treat a reader + that reports more bytes than the buffer holds as a read failure, because the + count is only meaningful as a read result. After any failure, drain the + remaining child output so the child cannot block on a full pipe. + +#### Failure classification and telemetry + +A forwarding failure is classified as one of `read`, `write`, `flush`, or +`drain`, and attributed to `stdout` or `stderr`. A drain failure after an +earlier failure is counted separately and never replaces the failure that +stopped forwarding. The counter `netsuke_ninja_forward_failures_total` carries +exactly the two labels `stream` and `failure_kind`, whose permitted values are +the closed arrays `FORWARD_STREAM_VALUES` and `FORWARD_FAILURE_KIND_VALUES`. +`ConfigMetricsRecorder` admits only those exact series, so a widened label +value is dropped rather than retained. Each failure also emits a warning event +carrying the same two fields plus the I/O error. Neither label admits output +content, paths, or reader and writer types. Adding a failure kind means adding +an enum variant and its array entry together, never passing a raw string. + +#### Result policy + +Forwarding loss never changes the reported build result. The child's exit +status remains authoritative, and a truncated output stream is reported through +the classified counter and warning event instead. This keeps a deliberately +closed parent pipe, as produced by `netsuke build | head`, from being reported +as a build failure. `finalize_streaming` therefore joins the stderr forwarding +thread for its command-list attribution and discards the forwarding statistics +the helper has already published. The policy tests in +`src/runner/process/tests/mod.rs` cover a successful child, a failing child, +and a failed wait, each with forwarding loss. + ### Module: `runner::process::ninja::program` `src/runner/process/ninja/program.rs` owns the executable-resolution boundary. diff --git a/src/observability/recorder/mod.rs b/src/observability/recorder/mod.rs index 35fbd2f78..13bdeb6a4 100644 --- a/src/observability/recorder/mod.rs +++ b/src/observability/recorder/mod.rs @@ -34,7 +34,8 @@ use netsuke::{ }, manifest::{ENV_LOOKUP_OUTCOME_VALUES, ENV_LOOKUP_TOTAL}, runner::{ - BASH_PREFLIGHT_TOTAL, LEGACY_RECIPE_EXECUTION_DURATION, LEGACY_RECIPE_EXECUTIONS_TOTAL, + BASH_PREFLIGHT_TOTAL, FORWARD_FAILURE_LABELS, FORWARD_FAILURES_TOTAL, + LEGACY_RECIPE_EXECUTION_DURATION, LEGACY_RECIPE_EXECUTIONS_TOTAL, NINJA_STATUS_OVERSIZED_LINES_TOTAL, RECIPE_SHELL_RESOLUTIONS_TOTAL, }, stdlib::{ @@ -154,6 +155,7 @@ impl ConfigMetricsRecorder { | WHICH_RESOLUTION_TOTAL | MANIFEST_STRUCTURES_TOTAL | NINJA_STATUS_OVERSIZED_LINES_TOTAL + | FORWARD_FAILURES_TOTAL | SHELL_QUOTE_DIALECT_TOTAL ) || is_check_metric(name) || console::is_console_metric(name) @@ -206,6 +208,7 @@ impl ConfigMetricsRecorder { ("failure_category", &LEGACY_RECIPE_FAILURE_CATEGORIES), ], ), + FORWARD_FAILURES_TOTAL => exact_labels(key, &FORWARD_FAILURE_LABELS), FILTERED_TARGETS_TOTAL | FILTERED_ACTIONS_TOTAL | OMITTED_FILTERED_ENTRIES_TOTAL diff --git a/src/observability/recorder/tests/forwarding_tests.rs b/src/observability/recorder/tests/forwarding_tests.rs new file mode 100644 index 000000000..469540332 --- /dev/null +++ b/src/observability/recorder/tests/forwarding_tests.rs @@ -0,0 +1,114 @@ +//! Verify the bounded child-output forwarding failure counter series. +//! +//! The forwarding helper records `stream` and `failure_kind` from closed +//! vocabularies, so a series the recorder rejects is silently dropped: the +//! counter handle becomes a noop and no other test notices. These cases drive +//! the real filter through the production recorder, then assert both the +//! admitted combinations and the rejected widening attempts. + +use super::{ConfigMetricsRecorder, SnapshotEntry}; +use metrics::counter; +use metrics_util::{MetricKind, debugging::DebugValue}; +use netsuke::runner::{FORWARD_FAILURE_LABELS, FORWARD_FAILURES_TOTAL}; + +/// Return the retained total for one exact `(stream, failure_kind)` series. +fn retained_count(snapshot: &[SnapshotEntry], stream: &str, failure_kind: &str) -> u64 { + snapshot + .iter() + .find_map(|entry| { + if entry.0.kind() != MetricKind::Counter + || entry.0.key().name() != FORWARD_FAILURES_TOTAL + { + return None; + } + let labels: Vec<_> = entry.0.key().labels().collect(); + let is_exact = labels.len() == 2 + && labels + .iter() + .any(|label| label.key() == "stream" && label.value() == stream) + && labels + .iter() + .any(|label| label.key() == "failure_kind" && label.value() == failure_kind); + match (is_exact, &entry.3) { + (true, DebugValue::Counter(count)) => Some(*count), + _ => None, + } + }) + .unwrap_or(0) +} + +/// Retain every combination the forwarding helper can emit. +#[test] +fn recorder_retains_every_bounded_forwarding_failure_series() { + let [stream_label, failure_kind_label] = FORWARD_FAILURE_LABELS.map(|(label, _)| label); + let [streams, failure_kinds] = FORWARD_FAILURE_LABELS.map(|(_, values)| values); + + let recorder = ConfigMetricsRecorder::new(); + let snapshotter = recorder.snapshotter(); + + metrics::with_local_recorder(&recorder, || { + for stream in streams { + for failure_kind in failure_kinds { + counter!( + FORWARD_FAILURES_TOTAL, + stream_label => *stream, + failure_kind_label => *failure_kind, + ) + .increment(1); + } + } + }); + + let snapshot = snapshotter.snapshot().into_vec(); + assert_eq!( + snapshot.len(), + streams.len() * failure_kinds.len(), + "every closed stream and failure-kind pair should be retained: {snapshot:?}" + ); + for stream in streams { + for failure_kind in failure_kinds { + assert_eq!( + retained_count(&snapshot, stream, failure_kind), + 1, + "missing bounded series ({stream}, {failure_kind}): {snapshot:?}" + ); + } + } +} + +/// Reject series that widen either label vocabulary or add a label. +#[test] +fn recorder_rejects_widened_forwarding_failure_series() { + let recorder = ConfigMetricsRecorder::new(); + let snapshotter = recorder.snapshotter(); + + metrics::with_local_recorder(&recorder, || { + counter!( + FORWARD_FAILURES_TOTAL, + "stream" => "private-output", + "failure_kind" => "read", + ) + .increment(1); + counter!( + FORWARD_FAILURES_TOTAL, + "stream" => "stdout", + "failure_kind" => "private-output", + ) + .increment(1); + counter!( + FORWARD_FAILURES_TOTAL, + "stream" => "stdout", + "failure_kind" => "read", + "target" => "private-target", + ) + .increment(1); + counter!(FORWARD_FAILURES_TOTAL, "stream" => "stdout").increment(1); + counter!(FORWARD_FAILURES_TOTAL).increment(1); + }); + + let snapshot = snapshotter.snapshot().into_vec(); + assert!( + snapshot.is_empty(), + "unbounded forwarding-failure series must not be retained: {snapshot:?}" + ); +} diff --git a/src/observability/recorder/tests/mod.rs b/src/observability/recorder/tests/mod.rs index 32934a2c6..3613e43ac 100644 --- a/src/observability/recorder/tests/mod.rs +++ b/src/observability/recorder/tests/mod.rs @@ -31,6 +31,9 @@ mod manifest_structure_tests; /// Cover the unlabelled oversized Ninja status counter separately. mod ninja_status_tests; +/// Cover the bounded child-output forwarding failure counter separately. +mod forwarding_tests; + /// Cover the bounded manifest environment-lookup counter series separately. mod env_lookup_tests; diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 24e4781cc..bae6b089f 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -66,9 +66,9 @@ pub use ninja::{NinjaContent, run_ninja, run_ninja_tool}; #[cfg(doctest)] pub use process::doc; pub use process::{ - CommandEnv, MAX_RETAINED_DYNDEP_FILES, NINJA_STATUS_OVERSIZED_LINES_TOTAL, NinjaBuildRequest, - NinjaJobCount, NinjaProcessOptions, NinjaToolRequest, StderrMode, run_ninja_tool_with, - run_ninja_with, + CommandEnv, FORWARD_FAILURE_LABELS, FORWARD_FAILURES_TOTAL, MAX_RETAINED_DYNDEP_FILES, + NINJA_STATUS_OVERSIZED_LINES_TOTAL, NinjaBuildRequest, NinjaJobCount, NinjaProcessOptions, + NinjaToolRequest, StderrMode, run_ninja_tool_with, run_ninja_with, }; pub use recipe_shell::telemetry::{ BASH_PREFLIGHT_TOTAL, LEGACY_RECIPE_EXECUTION_DURATION, LEGACY_RECIPE_EXECUTIONS_TOTAL, diff --git a/src/runner/process/child_exit.rs b/src/runner/process/child_exit.rs index 381298245..edd80b49d 100644 --- a/src/runner/process/child_exit.rs +++ b/src/runner/process/child_exit.rs @@ -102,17 +102,22 @@ pub(super) fn ninja_exit_error( } /// Join stderr forwarding and surface the child's wait result. +/// +/// Forwarding loss never changes the reported build result: the child's exit +/// status remains authoritative, and a truncated output stream is reported +/// through the classified forwarding-failure counter and a warning event +/// instead. This policy keeps a closed parent pipe, as produced by +/// `netsuke build | head`, from being reported as a build failure. Both +/// statistics are therefore intentionally discarded here; the forwarding +/// helper has already emitted their telemetry. pub(super) fn finalize_streaming( wait_result: io::Result, stdout_stats: ForwardStats, err_handle: thread::JoinHandle<(ForwardStats, Option)>, ) -> io::Result<(ExitStatus, Option)> { - handle_forwarding_stats(stdout_stats, "stdout"); + let _ = stdout_stats; let command_list_failure = match err_handle.join() { - Ok((stats, context)) => { - handle_forwarding_stats(stats, "stderr"); - context - } + Ok((_stderr_stats, context)) => context, Err(error) => { tracing::warn!("stderr forwarding thread panicked: {error:?}"); None @@ -120,10 +125,3 @@ pub(super) fn finalize_streaming( }; wait_result.map(|status| (status, command_list_failure)) } - -/// Log a truncation debug event when a forwarding stream hit a closed pipe. -fn handle_forwarding_stats(stats: ForwardStats, stream_name: &str) { - if stats.write_failed { - tracing::debug!("{stream_name} forwarding encountered closed pipe; output truncated"); - } -} diff --git a/src/runner/process/failure_attribution.rs b/src/runner/process/failure_attribution.rs index ca687557c..62134c020 100644 --- a/src/runner/process/failure_attribution.rs +++ b/src/runner/process/failure_attribution.rs @@ -3,7 +3,7 @@ use crate::ninja_gen::command_list::COMMAND_LIST_FAILURE_PREFIX; use std::io::{self, Write}; -use super::streaming::{ForwardStats, forward_child_output}; +use super::streaming::{ForwardStats, ForwardStream, forward_child_output}; /// Forward stderr while retaining only the bounded command-list failure marker. pub(super) fn forward_stderr_with_attribution( @@ -15,7 +15,7 @@ where W: Write, { let mut attribution_writer = FailureAttributionWriter::new(output); - let stats = forward_child_output(reader, &mut attribution_writer, "stderr"); + let stats = forward_child_output(reader, &mut attribution_writer, ForwardStream::Stderr); (stats, attribution_writer.into_failure()) } diff --git a/src/runner/process/mod.rs b/src/runner/process/mod.rs index e3488331f..0cca3b647 100644 --- a/src/runner/process/mod.rs +++ b/src/runner/process/mod.rs @@ -34,7 +34,9 @@ pub use file_io::*; use logging::{CommandLogContext, command_span, log_command_execution, log_command_spawn_failure}; pub use ninja::resolve_ninja_program; use output_forwarding::{StatusObserver, spawn_and_stream_output}; -pub use streaming::NINJA_STATUS_OVERSIZED_LINES_TOTAL; +pub use streaming::{ + FORWARD_FAILURE_LABELS, FORWARD_FAILURES_TOTAL, NINJA_STATUS_OVERSIZED_LINES_TOTAL, +}; mod configure; mod environment; diff --git a/src/runner/process/output_forwarding.rs b/src/runner/process/output_forwarding.rs index 9643d0a9f..eb4adbef0 100644 --- a/src/runner/process/output_forwarding.rs +++ b/src/runner/process/output_forwarding.rs @@ -6,7 +6,9 @@ use super::{ failure_attribution::{ CommandListFailure, NinjaFailureOutputTail, forward_stderr_with_attribution, }, - streaming::{ForwardStats, forward_child_output, forward_child_output_with_ninja_status}, + streaming::{ + ForwardStats, ForwardStream, forward_child_output, forward_child_output_with_ninja_status, + }, }; use std::{ io::{self, BufReader}, @@ -42,9 +44,13 @@ where BufReader::new(stdout), &mut tail_writer, observer, - "stdout", + ForwardStream::Stdout, + ), + None => forward_child_output( + BufReader::new(stdout), + &mut tail_writer, + ForwardStream::Stdout, ), - None => forward_child_output(BufReader::new(stdout), &mut tail_writer, "stdout"), }; return (stats, tail_writer.into_failure()); } @@ -54,9 +60,9 @@ where BufReader::new(stdout), output, observer, - "stdout", + ForwardStream::Stdout, ), - None => forward_child_output(BufReader::new(stdout), output, "stdout"), + None => forward_child_output(BufReader::new(stdout), output, ForwardStream::Stdout), }; (stats, None) } diff --git a/src/runner/process/streaming/forward_failure.rs b/src/runner/process/streaming/forward_failure.rs new file mode 100644 index 000000000..d5ba4a63f --- /dev/null +++ b/src/runner/process/streaming/forward_failure.rs @@ -0,0 +1,157 @@ +//! Classify child-output forwarding failures and bound their telemetry. +//! +//! Every forwarding failure reaches a [`ForwardFailureKind`] before it is +//! counted or logged, so neither a metric label nor a warning field can carry +//! a path, command line, or child output. The label vocabularies are closed +//! arrays: [`ForwardStream::label`] and [`ForwardFailureKind::label`] choose +//! values from those arrays, and the application recorder admits exactly the +//! same arrays, so a new label value cannot reach the retained snapshot +//! unless it is declared here first. + +use metrics::{counter, describe_counter}; +use std::{io, sync::Once}; + +/// Counter recording classified child-output forwarding failures. +pub const FORWARD_FAILURES_TOTAL: &str = "netsuke_ninja_forward_failures_total"; + +/// Bounded `stream` label values accepted for forwarding-failure telemetry. +pub const FORWARD_STREAM_VALUES: [&str; 2] = ["stdout", "stderr"]; + +/// Bounded `failure_kind` label values accepted for forwarding-failure telemetry. +pub const FORWARD_FAILURE_KIND_VALUES: [&str; 4] = ["read", "write", "flush", "drain"]; + +/// Complete labelled series admitted for the forwarding-failure counter. +/// +/// The recorder admits this pairing directly, so a label name and the +/// vocabulary behind it cannot drift apart from the arrays declared above. +pub const FORWARD_FAILURE_LABELS: [(&str, &[&str]); 2] = [ + ("stream", &FORWARD_STREAM_VALUES), + ("failure_kind", &FORWARD_FAILURE_KIND_VALUES), +]; + +/// One child stream whose output the process layer forwards. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::runner::process) enum ForwardStream { + /// The child's standard output. + Stdout, + /// The child's standard error. + Stderr, +} + +impl ForwardStream { + /// Return the bounded `stream` metric label for this stream. + pub(in crate::runner::process) const fn label(self) -> &'static str { + match self { + Self::Stdout => FORWARD_STREAM_VALUES[0], + Self::Stderr => FORWARD_STREAM_VALUES[1], + } + } +} + +/// Bounded classification of a child-output forwarding failure. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(in crate::runner::process) enum ForwardFailureKind { + /// The child stream could not be read. + Read, + /// The destination rejected a chunk. + Write, + /// The destination rejected the flush that publishes a chunk. + Flush, + /// Remaining child output could not be drained after a failure. + Drain, +} + +impl ForwardFailureKind { + /// Return the bounded `failure_kind` metric label for this classification. + pub(in crate::runner::process) const fn label(self) -> &'static str { + match self { + Self::Read => FORWARD_FAILURE_KIND_VALUES[0], + Self::Write => FORWARD_FAILURE_KIND_VALUES[1], + Self::Flush => FORWARD_FAILURE_KIND_VALUES[2], + Self::Drain => FORWARD_FAILURE_KIND_VALUES[3], + } + } +} + +/// A classified forwarding failure paired with the underlying I/O error. +/// +/// The error is retained only for the warning event's `error` field; it never +/// reaches a metric label. +#[derive(Debug)] +pub(super) struct ForwardFailure { + /// Classification reported through telemetry. + pub(super) kind: ForwardFailureKind, + /// Original error, reported in the warning event. + pub(super) source: io::Error, +} + +impl ForwardFailure { + /// Classify a failure to read the child stream. + pub(super) const fn read(source: io::Error) -> Self { + Self { + kind: ForwardFailureKind::Read, + source, + } + } + + /// Classify a failure to write a chunk to the destination. + pub(super) const fn write(source: io::Error) -> Self { + Self { + kind: ForwardFailureKind::Write, + source, + } + } + + /// Classify a failure to flush a written chunk to the destination. + pub(super) const fn flush(source: io::Error) -> Self { + Self { + kind: ForwardFailureKind::Flush, + source, + } + } +} + +/// Describe the forwarding-failure counter once per process. +fn describe_metric() { + static DESCRIBE: Once = Once::new(); + DESCRIBE.call_once(|| { + describe_counter!( + FORWARD_FAILURES_TOTAL, + "Counts child-output forwarding failures by stream and failure kind." + ); + }); +} + +/// Record one classified forwarding failure under bounded labels. +pub(super) fn record_failure(stream: ForwardStream, kind: ForwardFailureKind) { + describe_metric(); + counter!( + FORWARD_FAILURES_TOTAL, + "stream" => stream.label(), + "failure_kind" => kind.label() + ) + .increment(1); +} + +#[cfg(test)] +mod tests { + //! Verify the label vocabularies stay closed and distinct. + + use super::{ + FORWARD_FAILURE_KIND_VALUES, FORWARD_STREAM_VALUES, ForwardFailureKind, ForwardStream, + }; + + #[test] + fn labels_are_drawn_from_the_declared_vocabularies() { + assert_eq!(ForwardStream::Stdout.label(), FORWARD_STREAM_VALUES[0]); + assert_eq!(ForwardStream::Stderr.label(), FORWARD_STREAM_VALUES[1]); + for (kind, expected) in [ + (ForwardFailureKind::Read, FORWARD_FAILURE_KIND_VALUES[0]), + (ForwardFailureKind::Write, FORWARD_FAILURE_KIND_VALUES[1]), + (ForwardFailureKind::Flush, FORWARD_FAILURE_KIND_VALUES[2]), + (ForwardFailureKind::Drain, FORWARD_FAILURE_KIND_VALUES[3]), + ] { + assert_eq!(kind.label(), expected); + } + } +} diff --git a/src/runner/process/streaming/mod.rs b/src/runner/process/streaming/mod.rs index 21d48404c..2ee6f7349 100644 --- a/src/runner/process/streaming/mod.rs +++ b/src/runner/process/streaming/mod.rs @@ -1,4 +1,8 @@ //! Streaming helpers for subprocess output forwarding. +//! +//! The module owns the only byte-for-byte copy loop between a child's standard +//! streams and a forwarding writer. See `forward_failure` for the closed +//! failure vocabulary that keeps forwarding telemetry bounded. use super::ninja::{NinjaTaskProgressTracker, parse_ninja_status_line}; use metrics::{counter, describe_counter}; @@ -7,6 +11,11 @@ use std::{ sync::Once, }; +mod forward_failure; +pub use forward_failure::{FORWARD_FAILURE_LABELS, FORWARD_FAILURES_TOTAL}; +use forward_failure::{ForwardFailure, record_failure}; +pub(super) use forward_failure::{ForwardFailureKind, ForwardStream}; + /// Forwarding statistics for a child output stream. #[derive(Debug, Default, PartialEq, Eq, Clone, Copy)] pub(super) struct ForwardStats { @@ -14,8 +23,10 @@ pub(super) struct ForwardStats { pub(super) bytes_read: usize, /// Bytes accepted by the forwarding writer. pub(super) bytes_written: usize, - /// Whether the forwarding writer failed, truncating output. - pub(super) write_failed: bool, + /// Classification of the first forwarding failure, if forwarding failed. + pub(super) failure: Option, + /// Whether draining the remaining child output failed after that failure. + pub(super) drain_failed: bool, } /// Read wrapper that counts bytes surfaced to the caller. @@ -217,13 +228,14 @@ where /// /// # Errors /// -/// Returns an error when the writer rejects the chunk or its flush. -fn write_and_flush_chunk(writer: &mut W, chunk: &[u8]) -> io::Result<()> +/// Returns a classified failure when the writer rejects the chunk or its +/// flush. +fn write_and_flush_chunk(writer: &mut W, chunk: &[u8]) -> Result<(), ForwardFailure> where W: Write, { - writer.write_all(chunk)?; - writer.flush() + writer.write_all(chunk).map_err(ForwardFailure::write)?; + writer.flush().map_err(ForwardFailure::flush) } /// Copy and flush each child-read chunk before reading the next one. @@ -233,27 +245,32 @@ where /// /// # Errors /// -/// Returns the first error encountered while reading, writing, or flushing. -fn copy_and_flush(reader: &mut R, writer: &mut W) -> io::Result<()> +/// Returns the first failure encountered while reading, writing, or flushing, +/// classified by stage. A reader that reports more bytes than the buffer holds +/// is a read failure: the count is only meaningful as a read result. +fn copy_and_flush(reader: &mut R, writer: &mut W) -> Result<(), ForwardFailure> where R: Read, W: Write, { let mut buffer = [0_u8; 8 * 1024]; loop { - let count = read_next_chunk(reader, &mut buffer)?; + let count = read_next_chunk(reader, &mut buffer).map_err(ForwardFailure::read)?; if count == 0 { return Ok(()); } - let chunk = buffer - .get(..count) - .ok_or_else(|| io::Error::other("reader reported an invalid byte count"))?; + let chunk = buffer.get(..count).ok_or_else(|| { + ForwardFailure::read(io::Error::other("reader reported an invalid byte count")) + })?; write_and_flush_chunk(writer, chunk)?; } } /// Copy `reader` to `writer`, tracking statistics and draining on failure. -fn copy_with_stats(reader: &mut R, writer: &mut W, stream_name: &'static str) -> ForwardStats +/// +/// Byte counts are recorded on every path, including a failed drain, so they +/// always describe bytes the forwarder actually consumed and accepted. +fn copy_with_stats(reader: &mut R, writer: &mut W, stream: ForwardStream) -> ForwardStats where R: Read, W: Write, @@ -268,41 +285,58 @@ where written: 0, }; - match copy_and_flush(&mut counting_reader, &mut counting_writer) { - Ok(()) => { - stats.bytes_read = clamp_u64_to_usize(counting_reader.read); - stats.bytes_written = clamp_u64_to_usize(counting_writer.written); - } - Err(err) => { - stats.write_failed = true; - stats.bytes_read = clamp_u64_to_usize(counting_reader.read); - stats.bytes_written = clamp_u64_to_usize(counting_writer.written); - tracing::debug!( - "Failed to write child {stream_name} output to parent: {err}; discarding remaining bytes" + if let Err(failure) = copy_and_flush(&mut counting_reader, &mut counting_writer) { + stats.failure = Some(failure.kind); + record_failure(stream, failure.kind); + tracing::warn!( + stream = stream.label(), + failure_kind = failure.kind.label(), + error = %failure.source, + "failed to forward child output; discarding the remaining bytes" + ); + stats.drain_failed = drain_remaining(&mut counting_reader, stream); + } + + stats.bytes_read = clamp_u64_to_usize(counting_reader.read); + stats.bytes_written = clamp_u64_to_usize(counting_writer.written); + stats +} + +/// Drain the remaining child output after a forwarding failure. +/// +/// Draining keeps the child from blocking on a full pipe once the parent has +/// stopped consuming its output. A drain failure is recorded under its own +/// classification so it never replaces the failure that stopped forwarding. +fn drain_remaining(reader: &mut CountingReader<'_, R>, stream: ForwardStream) -> bool +where + R: Read, +{ + match io::copy(reader, &mut io::sink()) { + Ok(_) => false, + Err(error) => { + record_failure(stream, ForwardFailureKind::Drain); + tracing::warn!( + stream = stream.label(), + failure_kind = ForwardFailureKind::Drain.label(), + error = %error, + "failed to drain child output after a forwarding failure" ); - if let Err(drain_err) = io::copy(&mut counting_reader, &mut io::sink()) { - tracing::debug!( - "Failed to drain child {stream_name} output after writer closed: {drain_err}" - ); - } else { - stats.bytes_read = clamp_u64_to_usize(counting_reader.read); - } + true } } - stats } /// Forward child output to a writer while tracking read/write statistics. pub(super) fn forward_child_output( mut reader: R, mut writer: W, - stream_name: &'static str, + stream: ForwardStream, ) -> ForwardStats where R: Read, W: Write, { - copy_with_stats(&mut reader, &mut writer, stream_name) + copy_with_stats(&mut reader, &mut writer, stream) } /// Forward child output and parse Ninja status updates from complete lines. @@ -310,7 +344,7 @@ pub(super) fn forward_child_output_with_ninja_status( mut reader: R, mut writer: W, mut observer: F, - stream_name: &'static str, + stream: ForwardStream, ) -> ForwardStats where R: Read, @@ -324,7 +358,7 @@ where line_state: NinjaStatusLineState::Buffering, observer: &mut observer, }; - copy_with_stats(&mut parsing_reader, &mut writer, stream_name) + copy_with_stats(&mut parsing_reader, &mut writer, stream) } #[cfg(test)] diff --git a/src/runner/process/streaming/telemetry_tests.rs b/src/runner/process/streaming/telemetry_tests.rs index ca3e08025..5d275f945 100644 --- a/src/runner/process/streaming/telemetry_tests.rs +++ b/src/runner/process/streaming/telemetry_tests.rs @@ -1,6 +1,8 @@ //! Tests for bounded oversized Ninja status-line telemetry. -use super::{NINJA_STATUS_OVERSIZED_LINES_TOTAL, forward_child_output_with_ninja_status}; +use super::{ + ForwardStream, NINJA_STATUS_OVERSIZED_LINES_TOTAL, forward_child_output_with_ninja_status, +}; use metrics_util::{ MetricKind, debugging::{DebugValue, DebuggingRecorder}, @@ -22,7 +24,7 @@ fn records_oversized_status_lines_once_per_line() { Cursor::new(input), std::io::sink(), |_current, _total, _description| {}, - "stdout", + ForwardStream::Stdout, ); }); diff --git a/src/runner/process/streaming/tests.rs b/src/runner/process/streaming/tests.rs index 3af38448e..223159ec3 100644 --- a/src/runner/process/streaming/tests.rs +++ b/src/runner/process/streaming/tests.rs @@ -4,8 +4,8 @@ //! these parser checks cover only the progress-enabled stdout path. use super::{ - NinjaStatusLineState, NinjaStatusParsingReader, NinjaTaskProgressTracker, forward_child_output, - forward_child_output_with_ninja_status, + ForwardFailureKind, ForwardStream, NinjaStatusLineState, NinjaStatusParsingReader, + NinjaTaskProgressTracker, forward_child_output, forward_child_output_with_ninja_status, }; use proptest::prelude::*; use std::{ @@ -16,6 +16,8 @@ use std::{ }, }; +#[path = "tests/failure_tests.rs"] +mod failure_tests; #[path = "tests/flush_tests.rs"] mod flush_tests; @@ -114,7 +116,7 @@ where reader, &mut output, |current, total, description| updates.push((current, total, description.to_owned())), - "stdout", + ForwardStream::Stdout, ); assert_eq!(stats.bytes_read, input.len()); @@ -177,11 +179,11 @@ fn peak_rss_bytes() -> io::Result { fn forward_output_writes_all_bytes_when_parent_alive() { let input = b"alpha\nbravo\ncharlie\n".to_vec(); let reader = BufReader::new(Cursor::new(input.clone())); - let stats = forward_child_output(reader, Vec::new(), "stdout"); + let stats = forward_child_output(reader, Vec::new(), ForwardStream::Stdout); assert_eq!(stats.bytes_read, input.len()); assert_eq!(stats.bytes_written, input.len()); - assert!(!stats.write_failed); + assert_eq!(stats.failure, None); } #[test] @@ -190,11 +192,11 @@ fn forward_output_continues_draining_after_write_failure() { let reader = BufReader::new(Cursor::new(input.clone())); let write_attempts = Arc::new(AtomicUsize::new(0)); let failing_writer = FailingWriter::new(write_attempts.clone()); - let stats = forward_child_output(reader, failing_writer, "stdout"); + let stats = forward_child_output(reader, failing_writer, ForwardStream::Stdout); assert_eq!(stats.bytes_read, input.len()); assert_eq!(write_attempts.load(Ordering::SeqCst), 1); - assert!(stats.write_failed); + assert_eq!(stats.failure, Some(ForwardFailureKind::Write)); assert_eq!(stats.bytes_written, 0); } @@ -215,7 +217,7 @@ fn forward_output_with_ninja_status_parses_monotonic_updates() { |current, total, description| { updates.push((current, total, description.to_owned())); }, - "stdout", + ForwardStream::Stdout, ); assert_eq!(stats.bytes_read, input.len()); @@ -257,7 +259,7 @@ fn bounded_status_reader_forwards_large_unterminated_output() { ChunkedReader::new(input.clone(), vec![127; 4096]), &mut forwarded, |_current, _total, _description| {}, - "stdout", + ForwardStream::Stdout, ); assert_eq!(forwarded, input); @@ -297,7 +299,7 @@ fn split_status_line_updates_once_and_eof_partial_line_stays_bounded() { reader, &mut output, |current, total, description| updates.push((current, total, description.to_owned())), - "stdout", + ForwardStream::Stdout, ); assert_eq!(stats.bytes_read, input.len()); @@ -359,15 +361,18 @@ fn status_parsing_peak_rss_does_not_scale_with_a_large_unterminated_stream() { const RSS_ALLOWANCE_BYTES: u64 = 4 * 1024 * 1024; let plain_before = peak_rss_bytes().expect("getrusage should report initial RSS"); - let plain_stats = - forward_child_output(RepeatingByteReader::new(STREAM_BYTES), io::sink(), "stdout"); + let plain_stats = forward_child_output( + RepeatingByteReader::new(STREAM_BYTES), + io::sink(), + ForwardStream::Stdout, + ); let plain_after = peak_rss_bytes().expect("getrusage should report plain forwarding RSS"); let status_before = peak_rss_bytes().expect("getrusage should report status forwarding RSS"); let status_stats = forward_child_output_with_ninja_status( RepeatingByteReader::new(STREAM_BYTES), io::sink(), |_current, _total, _description| {}, - "stdout", + ForwardStream::Stdout, ); let status_after = peak_rss_bytes().expect("getrusage should report status parsing RSS"); diff --git a/src/runner/process/streaming/tests/failure_tests.rs b/src/runner/process/streaming/tests/failure_tests.rs new file mode 100644 index 000000000..bd17c7855 --- /dev/null +++ b/src/runner/process/streaming/tests/failure_tests.rs @@ -0,0 +1,334 @@ +//! Verify forwarding failures are classified and counted with bounded labels. +//! +//! Each case drives the production forwarding helper with an injected reader +//! or writer, then reads the counter series the helper registered through a +//! local debugging recorder. The assertions compare the exact label values, so +//! an unclassified or unbounded series fails the test rather than widening the +//! vocabulary. + +use super::super::{FORWARD_FAILURES_TOTAL, ForwardFailureKind, ForwardStream}; +use super::{ChunkedReader, forward_child_output}; +use metrics::{SharedString, Unit}; +use metrics_util::{ + CompositeKey, + debugging::{DebugValue, DebuggingRecorder}, +}; +use std::{ + collections::HashMap, + io::{self, Cursor, Read, Write}, +}; + +/// One entry of a drained debugging-recorder snapshot. +type SnapshotEntry = (CompositeKey, Option, Option, DebugValue); + +/// Collect the recorded forwarding-failure series as `(stream, kind, count)`. +fn failure_series(entries: &[SnapshotEntry]) -> Vec<(String, String, u64)> { + let mut series: Vec<_> = entries + .iter() + .filter(|entry| entry.0.key().name() == FORWARD_FAILURES_TOTAL) + .map(|entry| { + let labels: HashMap<_, _> = entry + .0 + .key() + .labels() + .map(|label| (label.key(), label.value().to_owned())) + .collect(); + let DebugValue::Counter(count) = &entry.3 else { + panic!("forwarding-failure series must be a counter: {entry:?}"); + }; + let stream = labels + .get("stream") + .cloned() + .expect("recorded forwarding-failure series must carry a stream label"); + let kind = labels + .get("failure_kind") + .cloned() + .expect("recorded forwarding-failure series must carry a failure_kind label"); + (stream, kind, *count) + }) + .collect(); + series.sort(); + series +} + +/// Reader that fails its first read once, then reports end of input. +struct FailsOnce { + /// Error kind reported by the failing read. + kind: io::ErrorKind, + /// Whether the failing read has already been served. + failed: bool, +} + +impl Read for FailsOnce { + fn read(&mut self, _buffer: &mut [u8]) -> io::Result { + if self.failed { + return Ok(0); + } + self.failed = true; + Err(io::Error::new(self.kind, "read failed")) + } +} + +/// Reader that returns one chunk and then fails while the forwarder drains. +struct FailsWhileDraining { + /// Bytes delivered by the first read. + bytes: Vec, + /// Whether the chunk has already been delivered. + delivered: bool, +} + +impl Read for FailsWhileDraining { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if self.delivered { + return Err(io::Error::other("drain read failed")); + } + self.delivered = true; + let count = self.bytes.len().min(buffer.len()); + let (Some(source), Some(destination)) = (self.bytes.get(..count), buffer.get_mut(..count)) + else { + // `count` is the smaller of the two lengths, so both slices + // always exist; report EOF rather than panicking if that changes. + return Ok(0); + }; + destination.copy_from_slice(source); + Ok(count) + } +} + +/// Reader that reports one byte more than the buffer can hold, then EOF. +#[derive(Default)] +struct OverlongOnce { + /// Whether the invalid count has already been reported. + reported: bool, +} + +impl Read for OverlongOnce { + fn read(&mut self, buffer: &mut [u8]) -> io::Result { + if self.reported { + return Ok(0); + } + self.reported = true; + Ok(buffer.len().saturating_add(1)) + } +} + +/// Reader that reports an interruption a fixed number of times before EOF. +struct Interrupting { + /// Interruptions still to report. + remaining: usize, +} + +impl Read for Interrupting { + fn read(&mut self, _buffer: &mut [u8]) -> io::Result { + if self.remaining > 0 { + self.remaining -= 1; + return Err(io::Error::new(io::ErrorKind::Interrupted, "interrupted")); + } + Ok(0) + } +} + +/// Writer that rejects every chunk with a closed pipe. +struct ClosedWriter; + +impl Write for ClosedWriter { + fn write(&mut self, _bytes: &[u8]) -> io::Result { + Err(io::Error::new(io::ErrorKind::BrokenPipe, "sink closed")) + } + + fn flush(&mut self) -> io::Result<()> { + Ok(()) + } +} + +/// Writer that accepts its chunk but fails the following flush. +#[derive(Default)] +struct FlushFails { + /// Number of chunk writes attempted. + writes: usize, + /// Bytes accepted across those writes. + accepted: Vec, +} + +impl Write for FlushFails { + fn write(&mut self, bytes: &[u8]) -> io::Result { + self.writes += 1; + self.accepted.extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> io::Result<()> { + Err(io::Error::new(io::ErrorKind::BrokenPipe, "flush failed")) + } +} + +#[test] +fn read_failure_is_classified_and_counted_once() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let stats = metrics::with_local_recorder(&recorder, || { + forward_child_output( + FailsOnce { + kind: io::ErrorKind::Other, + failed: false, + }, + io::sink(), + ForwardStream::Stdout, + ) + }); + + assert_eq!(stats.failure, Some(ForwardFailureKind::Read)); + assert!(!stats.drain_failed); + assert_eq!(stats.bytes_read, 0); + assert_eq!( + failure_series(&snapshotter.snapshot().into_vec()), + vec![("stdout".to_owned(), "read".to_owned(), 1)] + ); +} + +#[test] +fn invalid_read_count_is_classified_as_a_read_failure() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let stats = metrics::with_local_recorder(&recorder, || { + forward_child_output(OverlongOnce::default(), io::sink(), ForwardStream::Stdout) + }); + + assert_eq!(stats.failure, Some(ForwardFailureKind::Read)); + assert!(!stats.drain_failed); + assert_eq!( + failure_series(&snapshotter.snapshot().into_vec()), + vec![("stdout".to_owned(), "read".to_owned(), 1)] + ); +} + +#[test] +fn interrupted_reads_are_retried_without_recording_a_failure() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let input = b"retried".to_vec(); + let stats = metrics::with_local_recorder(&recorder, || { + forward_child_output( + io::Cursor::new(input.clone()).chain(Interrupting { remaining: 2 }), + Vec::new(), + ForwardStream::Stdout, + ) + }); + + assert_eq!(stats.failure, None); + assert!(!stats.drain_failed); + assert_eq!(stats.bytes_read, input.len()); + assert_eq!(stats.bytes_written, input.len()); + assert!( + failure_series(&snapshotter.snapshot().into_vec()).is_empty(), + "retried interruptions must not be recorded as terminal failures" + ); +} + +#[test] +fn write_failures_are_counted_for_each_stream_separately() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let (stdout_stats, stderr_stats) = metrics::with_local_recorder(&recorder, || { + let stdout = forward_child_output( + Cursor::new(b"lost".to_vec()), + ClosedWriter, + ForwardStream::Stdout, + ); + let stderr = forward_child_output( + Cursor::new(b"lost".to_vec()), + ClosedWriter, + ForwardStream::Stderr, + ); + (stdout, stderr) + }); + + assert_eq!(stdout_stats.failure, Some(ForwardFailureKind::Write)); + assert_eq!(stderr_stats.failure, Some(ForwardFailureKind::Write)); + assert_eq!(stdout_stats.bytes_read, 4); + assert_eq!(stdout_stats.bytes_written, 0); + assert_eq!( + failure_series(&snapshotter.snapshot().into_vec()), + vec![ + ("stderr".to_owned(), "write".to_owned(), 1), + ("stdout".to_owned(), "write".to_owned(), 1), + ] + ); +} + +#[test] +fn flush_failure_keeps_accepted_bytes_without_rewriting_them() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let (stats, writer) = metrics::with_local_recorder(&recorder, || { + let mut writer = FlushFails::default(); + let stats = forward_child_output( + ChunkedReader::new(b"firstlast".to_vec(), vec![5, 4]), + &mut writer, + ForwardStream::Stdout, + ); + (stats, writer) + }); + + assert_eq!(stats.failure, Some(ForwardFailureKind::Flush)); + assert!(!stats.drain_failed); + assert_eq!(writer.writes, 1, "accepted bytes must not be rewritten"); + assert_eq!(writer.accepted, b"first"); + assert_eq!(stats.bytes_read, "firstlast".len()); + assert_eq!(stats.bytes_written, "first".len()); + assert_eq!( + failure_series(&snapshotter.snapshot().into_vec()), + vec![("stdout".to_owned(), "flush".to_owned(), 1)] + ); +} + +#[test] +fn drain_failure_is_recorded_without_replacing_the_initiating_failure() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let stats = metrics::with_local_recorder(&recorder, || { + forward_child_output( + FailsWhileDraining { + bytes: b"first".to_vec(), + delivered: false, + }, + ClosedWriter, + ForwardStream::Stderr, + ) + }); + + assert_eq!(stats.failure, Some(ForwardFailureKind::Write)); + assert!(stats.drain_failed); + assert_eq!(stats.bytes_read, b"first".len()); + assert_eq!(stats.bytes_written, 0); + assert_eq!( + failure_series(&snapshotter.snapshot().into_vec()), + vec![ + ("stderr".to_owned(), "drain".to_owned(), 1), + ("stderr".to_owned(), "write".to_owned(), 1), + ] + ); +} + +#[test] +fn successful_forwarding_records_no_failure_series() { + let recorder = DebuggingRecorder::new(); + let snapshotter = recorder.snapshotter(); + let stats = metrics::with_local_recorder(&recorder, || { + forward_child_output( + Cursor::new(b"forwarded".to_vec()), + Vec::new(), + ForwardStream::Stdout, + ) + }); + + assert_eq!(stats.failure, None); + assert_eq!(stats.bytes_written, "forwarded".len()); + assert!( + snapshotter + .snapshot() + .into_vec() + .iter() + .all(|entry| { entry.0.key().name() != FORWARD_FAILURES_TOTAL }) + ); +} diff --git a/src/runner/process/streaming/tests/flush_tests.rs b/src/runner/process/streaming/tests/flush_tests.rs index 4becfecad..da4c1bb95 100644 --- a/src/runner/process/streaming/tests/flush_tests.rs +++ b/src/runner/process/streaming/tests/flush_tests.rs @@ -1,6 +1,6 @@ //! Verify each forwarded output chunk becomes visible before the next read. -use super::{ChunkedReader, forward_child_output}; +use super::{ChunkedReader, ForwardFailureKind, ForwardStream, forward_child_output}; use std::{ cell::RefCell, io::{self, Read, Write}, @@ -73,11 +73,11 @@ fn forward_output_flushes_each_complete_chunk_before_reading_more() { }; let mut writer = FlushBufferWriter::new(2, Rc::clone(&events)); - let stats = forward_child_output(reader, &mut writer, "stdout"); + let stats = forward_child_output(reader, &mut writer, ForwardStream::Stdout); assert_eq!(stats.bytes_read, input.len()); assert_eq!(stats.bytes_written, input.len()); - assert!(!stats.write_failed); + assert_eq!(stats.failure, None); assert_eq!( *events.borrow(), vec![ @@ -126,12 +126,12 @@ fn flush_failure_keeps_accepted_bytes_and_drains_the_remaining_input() { flush_count: 0, }; - let stats = forward_child_output(reader, &mut writer, "stdout"); + let stats = forward_child_output(reader, &mut writer, ForwardStream::Stdout); assert_eq!(writer.accepted, b"first"); assert_eq!(writer.write_count, 1, "accepted bytes must not be retried"); assert_eq!(writer.flush_count, 1); assert_eq!(stats.bytes_read, input.len(), "remaining bytes are drained"); assert_eq!(stats.bytes_written, b"first".len()); - assert!(stats.write_failed); + assert_eq!(stats.failure, Some(ForwardFailureKind::Flush)); } diff --git a/src/runner/process/tests/mod.rs b/src/runner/process/tests/mod.rs index ef5b4bc2c..903af6e1f 100644 --- a/src/runner/process/tests/mod.rs +++ b/src/runner/process/tests/mod.rs @@ -3,8 +3,11 @@ use super::child_exit::finalize_streaming; #[cfg(unix)] use super::list_failure_telemetry::COMMAND_LIST_FAILURE_DURATION; -use super::streaming::ForwardStats; use super::*; +use super::{ + failure_attribution::CommandListFailure, + streaming::{ForwardFailureKind, ForwardStats}, +}; use crate::test_tracing_capture::with_test_subscriber; use camino::{Utf8Path, Utf8PathBuf}; #[cfg(unix)] @@ -68,6 +71,75 @@ fn finalize_streaming_joins_stderr_thread_when_wait_fails() { ); } +/// Build forwarding statistics for a stream that lost part of its output. +fn failed_forwarding_stats(kind: ForwardFailureKind) -> ForwardStats { + ForwardStats { + failure: Some(kind), + ..ForwardStats::default() + } +} + +/// Report a child exit status without spawning a process. +#[cfg(unix)] +fn child_exit_status(code: i32) -> std::process::ExitStatus { + use std::os::unix::process::ExitStatusExt; + + std::process::ExitStatus::from_raw(code << 8) +} + +/// Report a child exit status without spawning a process. +#[cfg(windows)] +fn child_exit_status(code: u32) -> std::process::ExitStatus { + use std::os::windows::process::ExitStatusExt; + + std::process::ExitStatus::from_raw(code) +} + +/// Join a stderr forwarding thread that recorded no command-list failure. +fn joined_stderr( + stats: ForwardStats, +) -> thread::JoinHandle<(ForwardStats, Option)> { + thread::spawn(move || (stats, None)) +} + +#[test] +fn forwarding_loss_preserves_a_successful_child_result() { + let status = child_exit_status(0); + let result = finalize_streaming( + Ok(status), + failed_forwarding_stats(ForwardFailureKind::Flush), + joined_stderr(failed_forwarding_stats(ForwardFailureKind::Write)), + ) + .expect("forwarding loss must not turn a successful child into an error"); + + assert_eq!(result.0, status); + assert!(result.1.is_none()); +} + +#[test] +fn forwarding_loss_preserves_a_failed_child_result() { + let status = child_exit_status(1); + let result = finalize_streaming( + Ok(status), + failed_forwarding_stats(ForwardFailureKind::Write), + joined_stderr(ForwardStats::default()), + ) + .expect("the child's own exit status remains the reported result"); + + assert_eq!(result.0, status); +} + +#[test] +fn forwarding_loss_does_not_mask_a_wait_failure() { + let result = finalize_streaming( + Err(io::Error::other("simulated wait failure")), + failed_forwarding_stats(ForwardFailureKind::Read), + joined_stderr(failed_forwarding_stats(ForwardFailureKind::Drain)), + ); + + assert!(result.is_err(), "a wait() failure must still propagate"); +} + #[cfg(unix)] #[test] fn command_list_failure_duration_uses_the_injected_monotonic_clock() { From b8a5dbe8673b5dff6eeaa9c6a2e621f5f56c2388 Mon Sep 17 00:00:00 2001 From: leynos Date: Sat, 31 Oct 2026 19:11:18 +0100 Subject: [PATCH 21/27] Reduce CodeScene complexity in forwarding tests Split the recorder retention case into declared-series helpers so the test no longer nests loops, and compare each forwarding result with a single tuple assertion so no test carries a large assertion block. The overlong-read case keeps asserting the classification only: the impossible count is credited to `bytes_read` before the bounds check rejects it, so the test does not encode an accounting policy it does not own. CodeScene reported Bumpy Road Ahead in the recorder test and Large Assertion Blocks in the failure tests at 3f6f02ffe; both files now score 10.00 and introduce no new issue. Co-Authored-By: Claude Code --- .../recorder/tests/forwarding_tests.rs | 70 ++++++++++------- .../process/streaming/tests/failure_tests.rs | 78 ++++++++++++++----- 2 files changed, 103 insertions(+), 45 deletions(-) diff --git a/src/observability/recorder/tests/forwarding_tests.rs b/src/observability/recorder/tests/forwarding_tests.rs index 469540332..faa64a06f 100644 --- a/src/observability/recorder/tests/forwarding_tests.rs +++ b/src/observability/recorder/tests/forwarding_tests.rs @@ -37,43 +37,59 @@ fn retained_count(snapshot: &[SnapshotEntry], stream: &str, failure_kind: &str) .unwrap_or(0) } -/// Retain every combination the forwarding helper can emit. -#[test] -fn recorder_retains_every_bounded_forwarding_failure_series() { - let [stream_label, failure_kind_label] = FORWARD_FAILURE_LABELS.map(|(label, _)| label); +/// Enumerate every series the closed vocabularies declare. +fn declared_series() -> impl Iterator { let [streams, failure_kinds] = FORWARD_FAILURE_LABELS.map(|(_, values)| values); + streams + .iter() + .flat_map(move |stream| failure_kinds.iter().map(move |kind| (*stream, *kind))) +} - let recorder = ConfigMetricsRecorder::new(); - let snapshotter = recorder.snapshotter(); +/// Count the series the closed vocabularies declare. +fn declared_series_count() -> usize { + let [streams, failure_kinds] = FORWARD_FAILURE_LABELS.map(|(_, values)| values); + streams.len() * failure_kinds.len() +} - metrics::with_local_recorder(&recorder, || { - for stream in streams { - for failure_kind in failure_kinds { - counter!( - FORWARD_FAILURES_TOTAL, - stream_label => *stream, - failure_kind_label => *failure_kind, - ) - .increment(1); - } +/// Record one increment for every series the forwarding helper can emit. +fn record_declared_series(recorder: &ConfigMetricsRecorder) { + let [stream_label, failure_kind_label] = FORWARD_FAILURE_LABELS.map(|(label, _)| label); + metrics::with_local_recorder(recorder, || { + for (stream, failure_kind) in declared_series() { + counter!( + FORWARD_FAILURES_TOTAL, + stream_label => stream, + failure_kind_label => failure_kind, + ) + .increment(1); } }); +} - let snapshot = snapshotter.snapshot().into_vec(); +/// Assert every declared series was retained exactly once. +fn assert_every_declared_series_retained(snapshot: &[SnapshotEntry]) { + for (stream, failure_kind) in declared_series() { + assert_eq!( + retained_count(snapshot, stream, failure_kind), + 1, + "missing bounded series ({stream}, {failure_kind}): {snapshot:?}" + ); + } +} + +/// Retain every combination the forwarding helper can emit. +#[test] +fn recorder_retains_every_bounded_forwarding_failure_series() { + let recorder = ConfigMetricsRecorder::new(); + record_declared_series(&recorder); + + let snapshot = recorder.snapshotter().snapshot().into_vec(); assert_eq!( snapshot.len(), - streams.len() * failure_kinds.len(), + declared_series_count(), "every closed stream and failure-kind pair should be retained: {snapshot:?}" ); - for stream in streams { - for failure_kind in failure_kinds { - assert_eq!( - retained_count(&snapshot, stream, failure_kind), - 1, - "missing bounded series ({stream}, {failure_kind}): {snapshot:?}" - ); - } - } + assert_every_declared_series_retained(&snapshot); } /// Reject series that widen either label vocabulary or add a label. diff --git a/src/runner/process/streaming/tests/failure_tests.rs b/src/runner/process/streaming/tests/failure_tests.rs index bd17c7855..17cf4cb0b 100644 --- a/src/runner/process/streaming/tests/failure_tests.rs +++ b/src/runner/process/streaming/tests/failure_tests.rs @@ -196,6 +196,8 @@ fn invalid_read_count_is_classified_as_a_read_failure() { assert_eq!(stats.failure, Some(ForwardFailureKind::Read)); assert!(!stats.drain_failed); + // The impossible count is credited to `bytes_read` before the bounds check + // rejects it, so this case asserts the classification, not the accounting. assert_eq!( failure_series(&snapshotter.snapshot().into_vec()), vec![("stdout".to_owned(), "read".to_owned(), 1)] @@ -215,10 +217,16 @@ fn interrupted_reads_are_retried_without_recording_a_failure() { ) }); - assert_eq!(stats.failure, None); - assert!(!stats.drain_failed); - assert_eq!(stats.bytes_read, input.len()); - assert_eq!(stats.bytes_written, input.len()); + assert_eq!( + ( + stats.failure, + stats.drain_failed, + stats.bytes_read, + stats.bytes_written, + ), + (None, false, input.len(), input.len()), + "interrupted reads must be retried without being recorded" + ); assert!( failure_series(&snapshotter.snapshot().into_vec()).is_empty(), "retried interruptions must not be recorded as terminal failures" @@ -243,10 +251,24 @@ fn write_failures_are_counted_for_each_stream_separately() { (stdout, stderr) }); - assert_eq!(stdout_stats.failure, Some(ForwardFailureKind::Write)); - assert_eq!(stderr_stats.failure, Some(ForwardFailureKind::Write)); - assert_eq!(stdout_stats.bytes_read, 4); - assert_eq!(stdout_stats.bytes_written, 0); + assert_eq!( + ( + stdout_stats.failure, + stdout_stats.bytes_read, + stdout_stats.bytes_written, + ), + (Some(ForwardFailureKind::Write), 4, 0), + "a closed stdout pipe must be classified as a write failure" + ); + assert_eq!( + ( + stderr_stats.failure, + stderr_stats.bytes_read, + stderr_stats.bytes_written, + ), + (Some(ForwardFailureKind::Write), 4, 0), + "a closed stderr pipe must be classified as a write failure" + ); assert_eq!( failure_series(&snapshotter.snapshot().into_vec()), vec![ @@ -270,12 +292,26 @@ fn flush_failure_keeps_accepted_bytes_without_rewriting_them() { (stats, writer) }); - assert_eq!(stats.failure, Some(ForwardFailureKind::Flush)); - assert!(!stats.drain_failed); - assert_eq!(writer.writes, 1, "accepted bytes must not be rewritten"); - assert_eq!(writer.accepted, b"first"); - assert_eq!(stats.bytes_read, "firstlast".len()); - assert_eq!(stats.bytes_written, "first".len()); + assert_eq!( + ( + stats.failure, + stats.drain_failed, + stats.bytes_read, + stats.bytes_written, + ), + ( + Some(ForwardFailureKind::Flush), + false, + "firstlast".len(), + "first".len(), + ), + "a failed flush must keep the bytes it already accepted" + ); + assert_eq!( + (writer.writes, writer.accepted.as_slice()), + (1, b"first".as_slice()), + "accepted bytes must not be rewritten" + ); assert_eq!( failure_series(&snapshotter.snapshot().into_vec()), vec![("stdout".to_owned(), "flush".to_owned(), 1)] @@ -297,10 +333,16 @@ fn drain_failure_is_recorded_without_replacing_the_initiating_failure() { ) }); - assert_eq!(stats.failure, Some(ForwardFailureKind::Write)); - assert!(stats.drain_failed); - assert_eq!(stats.bytes_read, b"first".len()); - assert_eq!(stats.bytes_written, 0); + assert_eq!( + ( + stats.failure, + stats.drain_failed, + stats.bytes_read, + stats.bytes_written, + ), + (Some(ForwardFailureKind::Write), true, b"first".len(), 0), + "a drain failure must not replace the initiating write failure" + ); assert_eq!( failure_series(&snapshotter.snapshot().into_vec()), vec![ From 01c4a59c9fe43eaf27decf55e23d68aa5ffb08e7 Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 1 Nov 2026 08:04:09 +0100 Subject: [PATCH 22/27] Align console-pool locale terminology MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CodeRabbit's review of 3692a0958 raised seven minor localization findings, all of them terminology inconsistencies between the new console-pool diagnostics and the rest of their own catalogue: - Finnish, Gaelic, Hungarian and Japanese each named a build edge two ways across adjacent messages; the new strings now use the term the catalogue already established (Koontikaari, oir togail, építési él, ビルド辺). - Indonesian used "aksinya" where the file otherwise says "tindakan". - Korean attached a fixed particle to a placeable; the dual forms 은(는) and 을(를) match the rest of the file and the documented convention. - Hindi had replaced the established "बिल्ड कोर" with the transliterated "बिल्ड एज", so these messages now keep the existing term. Every change is limited to the affected message values; no placeables, keys or catalogue structure changed. make check-fmt, make doc-coverage, make lint, make test and make typecheck all pass on this change set. Co-Authored-By: Claude Code --- locales/fi/messages.ftl | 4 ++-- locales/gd/messages.ftl | 4 ++-- locales/hi/messages.ftl | 6 +++--- locales/hu/messages.ftl | 4 ++-- locales/id/messages.ftl | 4 ++-- locales/ja/messages.ftl | 4 ++-- locales/ko/messages.ftl | 4 ++-- 7 files changed, 15 insertions(+), 15 deletions(-) diff --git a/locales/fi/messages.ftl b/locales/fi/messages.ftl index e304c865f..e8943131b 100644 --- a/locales/fi/messages.ftl +++ b/locales/fi/messages.ftl @@ -199,8 +199,8 @@ ir.invalid_command = Virheellinen komennon sijoitus: { $snippet }. # Ninja-generoinnin virheet. ninja_gen.missing_action = Toiminto ”{ $id }”, johon koontikaari viittaa, puuttuu. -ninja_gen.console_dependency_only = Koontireuna ”{ $output }” ei voi valita konsolipoolia, koska sen toiminto sisältää vain riippuvuuksia. -ninja_gen.console_pool_conflict = Koontireuna ”{ $output }” valitsee konsolipoolin, mutta sen toiminto pyytää mukautettua poolia ”{ $pool }”. +ninja_gen.console_dependency_only = Koontikaari ”{ $output }” ei voi valita konsolipoolia, koska sen toiminto sisältää vain riippuvuuksia. +ninja_gen.console_pool_conflict = Koontikaari ”{ $output }” valitsee konsolipoolin, mutta sen toiminto pyytää mukautettua poolia ”{ $pool }”. ninja_gen.format = Ninja-manifestin tulostetta ei voitu muotoilla. ninja_gen.dyndep_files_required = Tämä toiminto vaatii luodun Ninja-paketin; käytä komentoa `netsuke build`, `netsuke clean` tai `netsuke generate`, jotta dyndep-tiedostot luodaan. ninja_gen.reserved_output_path = Polku '{ $path }' on varattu Netsuken sarjariippuvuuksien tilan tallentamiseen. diff --git a/locales/gd/messages.ftl b/locales/gd/messages.ftl index 7128552d5..4e435d793 100644 --- a/locales/gd/messages.ftl +++ b/locales/gd/messages.ftl @@ -199,8 +199,8 @@ ir.invalid_command = Cur a-steach mì-dhligheach san àithne: { $snippet }. # Mearachdan dèanamh Ninja. ninja_gen.missing_action = Tha an gnìomh “{ $id }” air a bheil oir togail a' toirt iomradh a dhìth. -ninja_gen.console_dependency_only = Chan urrainn do dh’iomall togail ‘{ $output }’ amar a’ chonsail a thaghadh oir chan eil san ghnìomh aige ach eisimeileachdan. -ninja_gen.console_pool_conflict = Tha iomall togail ‘{ $output }’ a’ taghadh amar a’ chonsail, ach tha an gnìomh aige ag iarraidh an amar ghnàthaichte ‘{ $pool }’. +ninja_gen.console_dependency_only = Chan urrainn do dh’oir togail ‘{ $output }’ amar a’ chonsail a thaghadh oir chan eil san ghnìomh aige ach eisimeileachdan. +ninja_gen.console_pool_conflict = Tha oir togail ‘{ $output }’ a’ taghadh amar a’ chonsail, ach tha an gnìomh aige ag iarraidh an amar ghnàthaichte ‘{ $pool }’. ninja_gen.format = Cha b' urrainnear às-chur an fhoirm-liosta Ninja fhòrmatadh. ninja_gen.dyndep_files_required = Feumaidh an togail seo pasgan Ninja a chaidh a chruthachadh; cleachd `netsuke build`, `netsuke clean` no `netsuke generate` gus na faidhlichean dyndep a chruthachadh. ninja_gen.reserved_output_path = Tha an t-slighe '{ $path }' glèidhte airson staid eisimeileachdan shreathach Netsuke. diff --git a/locales/hi/messages.ftl b/locales/hi/messages.ftl index c610c9909..527bbe0b1 100644 --- a/locales/hi/messages.ftl +++ b/locales/hi/messages.ftl @@ -198,9 +198,9 @@ ir.action_serialisation = क्रिया का क्रमांकन न ir.invalid_command = आदेश में अमान्य प्रक्षेपण: { $snippet }। # Ninja निर्माण की त्रुटियाँ। -ninja_gen.missing_action = किसी बिल्ड एज द्वारा संदर्भित क्रिया “{ $id }” अनुपस्थित है। -ninja_gen.console_dependency_only = बिल्ड एज ‘{ $output }’ कंसोल पूल नहीं चुन सकता, क्योंकि इसकी क्रिया केवल निर्भरताएँ बताती है। -ninja_gen.console_pool_conflict = बिल्ड एज ‘{ $output }’ कंसोल पूल चुनता है, लेकिन इसकी क्रिया कस्टम पूल ‘{ $pool }’ का अनुरोध करती है। +ninja_gen.missing_action = किसी बिल्ड कोर द्वारा संदर्भित क्रिया “{ $id }” अनुपस्थित है। +ninja_gen.console_dependency_only = बिल्ड कोर ‘{ $output }’ कंसोल पूल नहीं चुन सकता, क्योंकि इसकी क्रिया केवल निर्भरताएँ बताती है। +ninja_gen.console_pool_conflict = बिल्ड कोर ‘{ $output }’ कंसोल पूल चुनता है, लेकिन इसकी क्रिया कस्टम पूल ‘{ $pool }’ का अनुरोध करती है। ninja_gen.format = Ninja मैनिफ़ेस्ट का निर्गम स्वरूपित नहीं किया जा सका। ninja_gen.dyndep_files_required = इस बिल्ड के लिए जनरेट किया गया Ninja बंडल आवश्यक है; dyndep फ़ाइलों को मूर्त रूप देने के लिए `netsuke build`, `netsuke clean` या `netsuke generate` का उपयोग करें। ninja_gen.reserved_output_path = पथ '{ $path }' Netsuke की क्रमिक निर्भरता स्थिति के लिए आरक्षित है। diff --git a/locales/hu/messages.ftl b/locales/hu/messages.ftl index 75e691501..d2f51371c 100644 --- a/locales/hu/messages.ftl +++ b/locales/hu/messages.ftl @@ -199,8 +199,8 @@ ir.invalid_command = Érvénytelen behelyettesítés a parancsban: { $snippet }. # A Ninja-fájlok előállításának hibái. ninja_gen.missing_action = Hiányzik a(z) „{ $id }” művelet, amelyre egy építési él hivatkozik. -ninja_gen.console_dependency_only = A(z) „{ $output }” build él nem választhatja a konzolkészletet, mert a művelete csak függőségeket tartalmaz. -ninja_gen.console_pool_conflict = A(z) „{ $output }” build él a konzolkészletet választja, de a művelete a(z) „{ $pool }” egyéni készletet kéri. +ninja_gen.console_dependency_only = A(z) „{ $output }” építési él nem választhatja a konzolkészletet, mert a művelete csak függőségeket tartalmaz. +ninja_gen.console_pool_conflict = A(z) „{ $output }” építési él a konzolkészletet választja, de a művelete a(z) „{ $pool }” egyéni készletet kéri. ninja_gen.format = A Ninja-jegyzék kimenetét nem sikerült formázni. ninja_gen.dyndep_files_required = Ehhez a művelethez generált Ninja-csomag szükséges; a dyndep fájlok létrehozásához használja a `netsuke build`, `netsuke clean` vagy `netsuke generate` parancsot. ninja_gen.reserved_output_path = A(z) '{ $path }' útvonal a Netsuke soros függőségi állapota számára van fenntartva. diff --git a/locales/id/messages.ftl b/locales/id/messages.ftl index 466bc2a47..f6560f51b 100644 --- a/locales/id/messages.ftl +++ b/locales/id/messages.ftl @@ -199,8 +199,8 @@ ir.invalid_command = Penyisipan tidak sah pada perintah: { $snippet }. # Galat pembuatan berkas Ninja. ninja_gen.missing_action = Tindakan "{ $id }" yang dirujuk sebuah sisi build tidak ada. -ninja_gen.console_dependency_only = Sisi build ‘{ $output }’ tidak dapat memilih pool konsol karena aksinya hanya berisi dependensi. -ninja_gen.console_pool_conflict = Sisi build ‘{ $output }’ memilih pool konsol, tetapi aksinya meminta pool khusus ‘{ $pool }’. +ninja_gen.console_dependency_only = Sisi build ‘{ $output }’ tidak dapat memilih pool konsol karena tindakannya hanya berisi dependensi. +ninja_gen.console_pool_conflict = Sisi build ‘{ $output }’ memilih pool konsol, tetapi tindakannya meminta pool khusus ‘{ $pool }’. ninja_gen.format = Keluaran manifes Ninja tidak dapat diformat. ninja_gen.dyndep_files_required = Build ini memerlukan bundel Ninja yang dibuat; gunakan `netsuke build`, `netsuke clean`, atau `netsuke generate` agar berkas dyndep diwujudkan. ninja_gen.reserved_output_path = Jalur '{ $path }' dicadangkan untuk status dependensi serial Netsuke. diff --git a/locales/ja/messages.ftl b/locales/ja/messages.ftl index aa653a48d..dcc1167a4 100644 --- a/locales/ja/messages.ftl +++ b/locales/ja/messages.ftl @@ -199,8 +199,8 @@ ir.invalid_command = コマンドの補間が無効です: { $snippet }。 # Ninja 生成のエラー。 ninja_gen.missing_action = ビルド辺が参照するアクション「{ $id }」がありません。 -ninja_gen.console_dependency_only = ビルドエッジ「{ $output }」はコンソールプールを選択できません。アクションが依存関係のみだからです。 -ninja_gen.console_pool_conflict = ビルドエッジ「{ $output }」はコンソールプールを選択していますが、アクションはカスタムプール「{ $pool }」を要求しています。 +ninja_gen.console_dependency_only = ビルド辺「{ $output }」はコンソールプールを選択できません。アクションが依存関係のみだからです。 +ninja_gen.console_pool_conflict = ビルド辺「{ $output }」はコンソールプールを選択していますが、アクションはカスタムプール「{ $pool }」を要求しています。 ninja_gen.format = Ninja マニフェストの出力を整形できませんでした。 ninja_gen.dyndep_files_required = この操作には生成済み Ninja バンドルが必要です。dyndep ファイルを具体化するには `netsuke build`、`netsuke clean`、または `netsuke generate` を使用してください。 ninja_gen.reserved_output_path = パス '{ $path }' は Netsuke の直列依存関係の状態用に予約されています。 diff --git a/locales/ko/messages.ftl b/locales/ko/messages.ftl index 264940876..bc10f36ba 100644 --- a/locales/ko/messages.ftl +++ b/locales/ko/messages.ftl @@ -199,8 +199,8 @@ ir.invalid_command = 명령의 보간이 잘못되었습니다: { $snippet }. # Ninja 생성 오류. ninja_gen.missing_action = 빌드 간선이 참조하는 동작 '{ $id }'이(가) 없습니다. -ninja_gen.console_dependency_only = 빌드 간선 ‘{ $output }’는 콘솔 풀을 선택할 수 없습니다. 작업이 종속성만으로 이루어져 있기 때문입니다. -ninja_gen.console_pool_conflict = 빌드 간선 ‘{ $output }’는 콘솔 풀을 선택하지만 작업은 사용자 지정 풀 ‘{ $pool }’을 요청합니다. +ninja_gen.console_dependency_only = 빌드 간선 ‘{ $output }’은(는) 콘솔 풀을 선택할 수 없습니다. 작업이 종속성만으로 이루어져 있기 때문입니다. +ninja_gen.console_pool_conflict = 빌드 간선 ‘{ $output }’은(는) 콘솔 풀을 선택하지만 작업은 사용자 지정 풀 ‘{ $pool }’을(를) 요청합니다. ninja_gen.format = Ninja 매니페스트 출력의 서식을 지정하지 못했습니다. ninja_gen.dyndep_files_required = 이 작업에는 생성된 Ninja 번들이 필요합니다. dyndep 파일을 생성하려면 `netsuke build`, `netsuke clean` 또는 `netsuke generate`를 사용하세요. ninja_gen.reserved_output_path = 경로 '{ $path }'은(는) Netsuke의 직렬 종속성 상태용으로 예약되어 있습니다. From 188c85e5fc2c2ece58cf38ac5eaf426cd8c6a96c Mon Sep 17 00:00:00 2001 From: leynos Date: Sun, 1 Nov 2026 12:31:42 +0100 Subject: [PATCH 23/27] Align console pool terms with the glossary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ninja schedules console output through a construct it calls a pool, but the glossary had no record of the term, so the Romanian and Vietnamese catalogues rendered it with a generic group word ("grupul console", "nhóm console"). That reads as an ordinary group of console entries rather than Ninja's scheduling pool. Record `pool` in the source terminology table and map it in both locale tables: Romanian keeps the loan word, whose definite article takes a hyphen ("pool-ul consolei", "pool-ul personalizat"), and Vietnamese keeps "pool console" and "pool tùy chỉnh" uninflected. The three catalogue messages in each locale now use those forms, leaving every key, placeable, and catalogue structure unchanged. Co-Authored-By: Claude Code --- docs/localization-glossary.md | 5 ++++- locales/ro/messages.ftl | 6 +++--- locales/vi/messages.ftl | 6 +++--- 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/docs/localization-glossary.md b/docs/localization-glossary.md index 19a900e2f..7f19b9d13 100644 --- a/docs/localization-glossary.md +++ b/docs/localization-glossary.md @@ -80,6 +80,7 @@ Table 2: manifest and build-model terms | order-only dependency | `order-only dependency` | — | `weak dependency` | | build graph | `build graph` | `dependency graph`; `graph` | — | | build edge | `build edge` | `edge` | — | +| pool | `pool` | `Ninja pool`; `console pool`; `custom pool` | `queue`; `thread pool` | | phony target | `phony target` | `implicitly phony target` | `fake target`; `false target` | | default target | `default target` | `manifest default` | — | | build | `build` (noun and verb) | `build run` | `compilation` (generic use) | @@ -2248,7 +2249,7 @@ inappropriately casual for a build tool's diagnostics. Several Netsuke terms conventionally stay as English loan words in Romanian technical writing: **build** (as a noun, e.g. "un build reușit"), **cache**, **template** (though "șablon" is also common and preferred for Netsuke's Jinja -templates), **stdlib**, **dyndep**, **phony**, and **pipeline**. +templates), **stdlib**, **dyndep**, **phony**, **pool**, and **pipeline**. **Allowlist**/**blocklist** are calqued, not borrowed, per Microsoft's inclusive-language table (see below). **Macro** and **glob** are established loans that need no gloss. Loans are written in Latin script with standard @@ -2284,6 +2285,7 @@ Table 31: Romanian terminology | target | țintă | Distinct from acțiune/regulă; standard in Romanian Makefile/build tutorials. | | action | acțiune | Netsuke's implicitly phony target; keep distinct from țintă and regulă. | | rule | regulă | The Ninja rule construct a target references. | +| pool | pool | Loan word, like "build" and "cache", whose definite article also takes a hyphen: "pool-ul consolei", "pool-ul personalizat". | | dependency | dependență (pl. dependențe) | Standard Romanian computing term. | | order-only dependency | dependență doar-de-ordine | Calque; gloss as "dependență ce `impune` doar ordinea" on first use if needed. | | build (noun) | build | Loan word; widely used untranslated in Romanian developer writing, e.g. "un build reușit". | @@ -2801,6 +2803,7 @@ Table 37: Vietnamese terminology | target | mục tiêu | Follows [Bazel Vietnamese docs](https://bazel.build/versions/7.4.0/extending/rules?hl=vi); "đích" also seen in GNU Make/manpage translations | | action | hành động | Follows [Bazel Action docs](https://bazel.build/rules/lib/builtins/Action?hl=vi); distinct from "thao tác" (operation) | | rule | quy tắc | Confirmed across Bazel, gcc, and Poedit `vi.po` translations | +| pool | pool | Loan word; compounds stay uninflected: "pool console", "pool tùy chỉnh". Avoid "nhóm" (generic group). | | dependency | phần phụ thuộc | Confirmed in Bazel, Git, and Debian apt `vi.po` translations | | order-only dependency | phần phụ thuộc chỉ theo thứ tự | Calque; Ninja's order-only concept has no established Vietnamese term | | build (noun) | bản dựng | Confirmed in [Bazel Vietnamese user manual](https://bazel.build/versions/8.1.0/docs/user-manual?hl=vi) | diff --git a/locales/ro/messages.ftl b/locales/ro/messages.ftl index 95de89ed1..9f3276623 100644 --- a/locales/ro/messages.ftl +++ b/locales/ro/messages.ftl @@ -186,7 +186,7 @@ manifest.glob.unknown_io_error = eroare de intrare/ieșire necunoscută. manifest.command_list_empty = Câmpul „command” nu trebuie să fie gol: furnizați un șir de comandă sau o listă nevidă. manifest.command_isolation_recipe = Câmpul „command_isolation” necesită o rețetă cu o listă de comenzi. manifest.command_isolation_scalar = „command_isolation: isolated” necesită o listă de comenzi. -manifest.console_dependency_only = Intrările care declară doar dependențe nu pot selecta grupul console. +manifest.console_dependency_only = Intrările care declară doar dependențe nu pot selecta pool-ul consolei. # Erori ale reprezentării intermediare. ir.rule_not_found = Regula „{ $rule }” la care face referire ținta „{ $target }” nu a fost găsită. @@ -199,8 +199,8 @@ ir.invalid_command = Interpolare nevalidă în comandă: { $snippet }. # Erori la generarea fișierelor Ninja. ninja_gen.missing_action = Lipsește acțiunea „{ $id }” la care face referire o muchie de construire. -ninja_gen.console_dependency_only = Muchia de construire „{ $output }” nu poate selecta grupul console, deoarece acțiunea sa conține doar dependențe. -ninja_gen.console_pool_conflict = Muchia de construire „{ $output }” selectează grupul console, dar acțiunea sa solicită grupul personalizat „{ $pool }”. +ninja_gen.console_dependency_only = Muchia de construire „{ $output }” nu poate selecta pool-ul consolei, deoarece acțiunea sa conține doar dependențe. +ninja_gen.console_pool_conflict = Muchia de construire „{ $output }” selectează pool-ul consolei, dar acțiunea sa solicită pool-ul personalizat „{ $pool }”. ninja_gen.format = Ieșirea manifestului Ninja nu a putut fi formatată. ninja_gen.dyndep_files_required = Această operație necesită un pachet Ninja generat; utilizați `netsuke build`, `netsuke clean` sau `netsuke generate` pentru a materializa fișierele dyndep. ninja_gen.reserved_output_path = Calea '{ $path }' este rezervată pentru starea dependențelor seriale Netsuke. diff --git a/locales/vi/messages.ftl b/locales/vi/messages.ftl index 6b0333aa9..9ab5632c1 100644 --- a/locales/vi/messages.ftl +++ b/locales/vi/messages.ftl @@ -186,7 +186,7 @@ manifest.glob.unknown_io_error = lỗi vào/ra không xác định. manifest.command_list_empty = Trường “command” không được để trống: hãy cung cấp một chuỗi lệnh hoặc một danh sách không rỗng. manifest.command_isolation_recipe = Trường “command_isolation” yêu cầu công thức có danh sách lệnh. manifest.command_isolation_scalar = “command_isolation: isolated” yêu cầu danh sách lệnh. -manifest.console_dependency_only = Các mục chỉ khai báo phần phụ thuộc không thể chọn nhóm console. +manifest.console_dependency_only = Các mục chỉ khai báo phần phụ thuộc không thể chọn pool console. # Lỗi của biểu diễn trung gian. ir.rule_not_found = Không tìm thấy quy tắc “{ $rule }” mà đích “{ $target }” tham chiếu. @@ -199,8 +199,8 @@ ir.invalid_command = Nội suy không hợp lệ trong lệnh: { $snippet }. # Lỗi khi tạo tệp Ninja. ninja_gen.missing_action = Thiếu hành động “{ $id }” mà một cạnh dựng tham chiếu. -ninja_gen.console_dependency_only = Cạnh dựng “{ $output }” không thể chọn nhóm console vì hành động của nó chỉ chứa các phụ thuộc. -ninja_gen.console_pool_conflict = Cạnh dựng “{ $output }” chọn nhóm console, nhưng hành động yêu cầu nhóm tùy chỉnh “{ $pool }”. +ninja_gen.console_dependency_only = Cạnh dựng “{ $output }” không thể chọn pool console vì hành động của nó chỉ chứa các phụ thuộc. +ninja_gen.console_pool_conflict = Cạnh dựng “{ $output }” chọn pool console, nhưng hành động yêu cầu pool tùy chỉnh “{ $pool }”. ninja_gen.format = Không định dạng được đầu ra của tệp kê khai Ninja. ninja_gen.dyndep_files_required = Thao tác này yêu cầu các tệp Ninja dyndep đã tạo; hãy dùng `netsuke build`, `netsuke clean` hoặc `netsuke generate` để hiện thực hóa chúng. ninja_gen.reserved_output_path = Đường dẫn '{ $path }' được dành riêng cho trạng thái phụ thuộc tuần tự của Netsuke. From f56c27ef21ef702790e9a8d1fe605feb812454ba Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 4 Nov 2026 06:54:08 +0100 Subject: [PATCH 24/27] Refine edge visibility after rebase Keep the rebased manifest loader and cycle diagnostics while carrying the console visibility setting through its current edge API. Make visibility defaults explicit in test fixtures, refresh affected serialization snapshots, and correct the external-crate doctest path. This integrates the console-pool change with the current main branch. --- ...elect-ninja-console-pool-per-build-edge.md | 28 ++++++---- docs/developers-guide.md | 48 ++++++++++------ docs/netsuke-design.md | 56 ++++++++++--------- docs/v0-1-0-migration-guide.md | 47 ++++------------ src/graph_view/tests/support.rs | 2 +- src/ir/cycle/participation_tests.rs | 1 + src/ir/cycle/property_tests/analyse_tests.rs | 2 +- .../property_tests/issue322_property_tests.rs | 2 +- src/ir/cycle/property_tests/mod.rs | 2 +- .../property_tests/participation_tests.rs | 1 + src/ir/cycle/tests.rs | 2 +- src/ir/cycle/verification.rs | 2 +- src/ir/from_manifest/mod.rs | 8 ++- src/ir/graph/error.rs | 26 +-------- src/ir/graph/mod.rs | 23 ++++++-- src/ir/mod.rs | 10 ++-- src/manifest/mod.rs | 7 ++- .../property_tests/defaults/generator.rs | 2 +- .../ninja_oracle/relay_regression.rs | 8 +-- .../determinism/property_tests/no_loss.rs | 2 +- .../determinism/property_tests/order.rs | 2 +- .../determinism/strategy/graph/blueprint.rs | 2 +- src/ninja_gen/display_edge.rs | 2 +- src/ninja_gen/dyndep/mod.rs | 7 ++- src/ninja_gen/dyndep/tests/console.rs | 8 +-- src/ninja_gen/dyndep/tests/dependency_only.rs | 2 +- src/ninja_gen/dyndep/tests/mod.rs | 2 +- src/ninja_gen/mod.rs | 10 ++-- src/ninja_gen/progress_label.rs | 2 +- src/ninja_gen/property_tests/console.rs | 14 ++++- .../property_tests/dependency_only.rs | 2 +- src/ninja_gen/property_tests/edge_strategy.rs | 2 +- src/ninja_gen/property_tests/mod.rs | 2 +- src/ninja_gen/property_tests/ninja_oracle.rs | 2 +- src/ninja_gen/tests/console.rs | 8 ++- src/ninja_gen/tests/mod.rs | 8 +-- src/ninja_gen/validation.rs | 7 ++- src/runner/dyndep/generation_telemetry.rs | 11 +++- .../process/streaming/tests/flush_tests.rs | 47 ++++++++++++++++ src/runner/process/test_fixtures.rs | 2 +- .../process/tests/command_list_targets.rs | 2 +- src/runner/tests/mod.rs | 2 +- .../from_path_with_limits.snap | 3 + tests/ast_tests/dependency_only.rs | 35 +++++++----- tests/bdd/steps/ninja.rs | 2 +- tests/ir_tests.rs | 8 +-- tests/ninja_dollar_escaping_tests.rs | 2 +- ...inja_gen_command_list_integration_tests.rs | 4 +- ..._command_list_process_integration_tests.rs | 2 +- ...ninja_gen_inline_progress_process_tests.rs | 4 +- tests/ninja_gen_integration_tests.rs | 14 ++--- .../progress_localization.rs | 2 +- tests/ninja_gen_unit_tests.rs | 16 +++--- tests/serial_dependency_runtime_tests.rs | 2 +- .../manifest_load_options/from_path.snap | 7 ++- .../from_path_with_policy.snap | 7 ++- .../from_path_with_policy_and_env.snap | 8 ++- ...m_path_with_policy_and_env_and_limits.snap | 8 ++- ...from_path_with_policy_and_environment.snap | 8 ++- ...ith_policy_and_environment_and_limits.snap | 8 ++- .../from_path_with_policy_and_limits.snap | 7 ++- .../manifest_load_options/from_str.snap | 5 +- .../from_str_with_env.snap | 6 +- .../from_str_with_env_and_config.snap | 7 ++- .../from_str_with_env_and_policy.snap | 6 +- tests/ui/build_edge_console_compile_pass.rs | 6 +- 66 files changed, 372 insertions(+), 230 deletions(-) diff --git a/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md b/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md index e1c9252d2..47cec8c57 100644 --- a/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md +++ b/docs/adrs/adr-0061-select-ninja-console-pool-per-build-edge.md @@ -2,9 +2,10 @@ ## Status -Accepted. Actions and targets may set `console: true`; Netsuke carries that -choice on `BuildEdge` and writes `pool = console` on the generated build edge. -The option defaults to `false` and does not change `Action` identity. +Accepted. Actions and targets may set `console: true`; manifest lowering stores +the backend-neutral request as `BuildEdge.output_visibility`, and the Ninja +adapter maps `Immediate` to `pool = console` on the generated build edge. The +option defaults to `false` and does not change `Action` identity. ## Date @@ -35,7 +36,12 @@ owner. ## Decision outcome - Add a default-false `console` Boolean to manifest actions and targets, and - carry it as `BuildEdge.console`. + lower it to `EdgeOutputVisibility::Immediate` or `Buffered` on the + `BuildEdge`. +- Keep `EdgeOutputVisibility` backend-neutral. Manifest lowering owns the + conversion from the YAML option; each backend owns the mapping to its + scheduling and output mechanisms. The Ninja adapter alone knows the pool name + and edge-level clearing syntax. - Emit an edge-level `pool = console` binding only for selected user edges. When an action rule declares `pool = console`, emit an empty edge-level `pool =` binding on its ordinary edges so they do not inherit console @@ -50,13 +56,13 @@ owner. - Validate these rules before both direct Ninja generation and dyndep-bundle generation, preserving the existing missing-action diagnostic path. -Adding the field to the public `Target` and `BuildEdge` structs breaks -downstream Rust callers that construct either type with a struct literal. Both -structs expose public fields and neither is `#[non_exhaustive]`, so an existing -literal fails to compile until it names `console`. The package currently -declares version `0.1.0-beta4`, and this source break is accepted for the next -pre-release API: callers must provide `console`. The default only preserves -manifest compatibility through Serde. +Adding fields to the public `Target` and `BuildEdge` structs breaks downstream +Rust callers that construct either type with a struct literal. Both structs +expose public fields and neither is `#[non_exhaustive]`, so callers must add +`console` to `Target` and `output_visibility` to `BuildEdge`. The package +currently declares version `0.1.0-beta4`, and this source break is accepted for +the next pre-release API. The default only preserves manifest compatibility +through Serde. Ninja runs console-pool edges one at a time in one invocation. Output from other edges is held while a console edge runs. Netsuke forwards Ninja output as diff --git a/docs/developers-guide.md b/docs/developers-guide.md index e73963408..b2ba5ab21 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -6502,29 +6502,35 @@ variants, and `parallel` remains the default. The ordering policy applies only to a manifest `deps` list; never infer it from the number or shape of graph edges, and do not apply it to inputs or order-only dependencies. -The manifest `console` Boolean lowers to `BuildEdge.console`. It is scheduling -metadata owned by the edge because two targets can share one action and rule -while only one target needs streamed output. `Action.pool` remains unchanged, -so console selection does not alter action identity or action deduplication. -`src/ninja_gen/display_edge.rs` emits `pool = console` on that edge. If a -shared action rule already declares `pool = console`, ordinary edges get an -empty edge-level `pool =` binding to clear that inherited selection. Shared -console-scheduling validation runs before both direct Ninja generation and -dyndep-bundle generation. That validation rejects dependency-only edges and -custom action pools that conflict with `console`; it accepts an existing -`Action.pool` value of `console`. Synthetic serial gates and dyndep sidecars -never inherit either binding. +The manifest `console` Boolean lowers to `BuildEdge.output_visibility`, using +`EdgeOutputVisibility::Immediate` for `true` and `Buffered` for `false`. This +backend-neutral request belongs to the edge because two targets can share one +action and rule while only one target needs streamed output. `Action.pool` +remains unchanged, so console selection does not alter action identity or +action deduplication. `src/ninja_gen/display_edge.rs` emits `pool = console` on +that edge. If a shared action rule already declares `pool = console`, ordinary +edges get an empty edge-level `pool =` binding to clear that inherited +selection. Shared console-scheduling validation runs before both direct Ninja +generation and dyndep-bundle generation. That validation rejects +dependency-only edges and custom action pools that conflict with `console`; it +accepts an existing `Action.pool` value of `console`. Synthetic serial gates +and dyndep sidecars never inherit either binding. The emitted `pool = console` binding requires Ninja 1.5 or newer. This minimum is separate from the conditional Ninja 1.10 requirement for staged serial dependency bundles described below. -AST validation keeps stable diagnostic details separate from localized display -text. `src/ir/graph/error.rs::manifest_recipe_validation_message` maps a -recognized manifest detail to the same user-facing message for parsing and -direct IR lowering. Ninja generator errors use a separate message when an -output edge is available; the build-script localization slice does not depend -on this library-only mapping. +`IrGenError::InvalidManifest` retains its stable English schema diagnostic when +formatted. Manifest loading localizes that diagnostic at the parsing boundary; +IR error formatting must not resolve the process-wide active locale. + +Manifest validation keeps stable diagnostic details separate from localized +display text. `src/manifest/mod.rs` maps a recognized detail to the localized +parse message. Direct IR lowering retains the stable detail in +`IrGenError::InvalidManifest`, whose formatting does not consult global locale +state. Ninja generator errors use an output-specific message when an edge is +available; the build-script localization slice does not depend on either +library-side path. `src/ir/cycle/detector.rs::CycleDetector::visit_known_edge` traverses `inputs`, `implicit_deps`, and `order_only_deps` when detecting cycles. Order-only @@ -7346,6 +7352,12 @@ generated text used by these tests and does not parse general Ninja syntax. Permitted call sites are test-support and test code only; production code must not depend on `test_support`. +`test_support::ninja_edge::edge_pool_bindings` returns the named edge's exact +`pool =` bindings. It distinguishes `pool = console` for an opted-in edge, an +empty `pool =` binding that clears an inherited console pool, and no binding +for an ordinary edge. Keep this helper within test-support and test code; it +does not parse general Ninja syntax. + The local `edge_factory` fixture in `src/ninja_gen/tests/console.rs` constructs parallel, single-output edges for console generator tests. Keep it within that module; other graph shapes use their own fixtures. diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index cdaf797d1..cb8be5a03 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -2359,12 +2359,12 @@ logic untouched. The IR keeps recipe and command data free of Ninja-specific syntax. Netsuke markers such as `{{ ins }}` and `{{ outs }}` are resolved to plain lists of -file paths, and command strings are expanded before hashing. The -`BuildEdge.console` scheduling flag described in §5.5 is a deliberate -Ninja-specific exception: it records a Boolean choice without storing a pool -name or placeholder. Recipe and command data therefore provide a stable -contract that future back-ends--distributed builders, remote executors, or -otherwise--can consume without modification. +file paths, and command strings are expanded before hashing. +`EdgeOutputVisibility` carries an edge's request for immediate or normally +buffered output without naming a backend mechanism. Each backend maps that +request to its own scheduling and forwarding behaviour. Recipe and command data +therefore provide a stable contract that future back-ends--distributed +builders, remote executors, or otherwise--can consume without modification. Furthermore, the IR is the ideal stage at which to perform graph-level analysis and optimizations, such as detecting circular dependencies, pruning unused @@ -2439,11 +2439,16 @@ pub enum EnvBinding { Unset, } +/// Desired output visibility, translated by each backend. +pub enum EdgeOutputVisibility { + Buffered, + Immediate, +} + /// Represents a single build statement, analogous to a Ninja 'build' edge. /// It connects a set of inputs to a set of outputs via an Action. The `phony` /// and `always` flags control execution when outputs already exist or when -/// timestamps would normally skip the step. `console` selects Ninja's special -/// console pool for this edge without changing the referenced Action. +/// timestamps would normally skip the step. pub struct BuildEdge { /// The unique identifier of the Action used for this edge. pub action_id: String, @@ -2475,8 +2480,8 @@ pub struct BuildEdge { /// Run this edge on every invocation regardless of timestamps. pub always: bool, - /// Run this edge in Ninja's special console pool. - pub console: bool, + /// Backend-neutral request for immediate or buffered output. + pub output_visibility: EdgeOutputVisibility, } ``` @@ -2512,7 +2517,7 @@ classDiagram +Vec order_only_deps +bool phony +bool always - +bool console + +EdgeOutputVisibility output_visibility } class EdgeId { +usize index @@ -2572,17 +2577,17 @@ This transformation involves several steps: fully resolved recipe and file set, so identical rule templates yield distinct actions when their paths differ. Create a corresponding `ir::BuildEdge` linking the target to the action identifier and transfer the - `phony`, `always`, and `console` flags. Console remains edge-level - scheduling metadata, so it does not change action hashing or rule sharing. - `sources` are lowered into the edge's explicit input list so recipe - interpolation and Ninja `$in` see only material inputs. `deps` are lowered - into a separate `implicit_deps` list, which maps to Ninja's implicit - dependency syntax (`|`) so Ninja orders and rebuilds them without exposing - them as recipe arguments; `order_only_deps` remains separate and maps to - Ninja's `||` class. The edge enters the arena once; every explicit output is - then indexed to its stable `EdgeId`. Duplicate outputs are rejected before - this mutation, so each alias resolves to one canonical edge without copying - its output vector. + `phony` and `always` flags. Lower the manifest `console` option to + `EdgeOutputVisibility::Immediate` or `Buffered`; this edge-level request + does not change action hashing or rule sharing. `sources` are lowered into + the edge's explicit input list so recipe interpolation and Ninja `$in` see + only material inputs. `deps` are lowered into a separate `implicit_deps` + list, which maps to Ninja's implicit dependency syntax (`|`) so Ninja orders + and rebuilds them without exposing them as recipe arguments; + `order_only_deps` remains separate and maps to Ninja's `||` class. The edge + enters the arena once; every explicit output is then indexed to its stable + `EdgeId`. Duplicate outputs are rejected before this mutation, so each alias + resolves to one canonical edge without copying its output vector. FUTURE: @@ -2882,9 +2887,10 @@ conceptual model while keeping most scheduling data backend-agnostic. insertion-ordered arena, and a map from `Utf8PathBuf` to `EdgeId` for output aliases. Actions hold the parsed `Recipe` and optional execution metadata. `BuildEdge` connects inputs to outputs using an action identifier and carries -the `phony`, `always`, and `console` flags verbatim from the manifest. The -`console` flag is a deliberate Ninja-specific edge annotation; the IR stores a -Boolean scheduling choice, not a pool name or placeholder. It does not affect +the `phony` and `always` flags from the manifest. Its `EdgeOutputVisibility` +field expresses the desired output behaviour without exposing a Ninja pool +name. The Ninja adapter maps `Immediate` to its console pool and leaves +`Buffered` edges in the normal pool. This edge-level choice does not affect action identity. - Actions are deduplicated using a SHA-256 hash of a canonical JSON diff --git a/docs/v0-1-0-migration-guide.md b/docs/v0-1-0-migration-guide.md index 8b489c9b0..9ab2a1e1f 100644 --- a/docs/v0-1-0-migration-guide.md +++ b/docs/v0-1-0-migration-guide.md @@ -15,7 +15,7 @@ sections that match the features they use. | Required | Linux checkout builds | Install the pinned `mold` linker before building from a checkout. | | Review if enabled | Manifest scheduling | Opt in only where immediate console output is useful. | | Required | `Target` literals | Add `description` and `console` to every literal. | -| Required | `BuildEdge` literals | Add `console` to every literal. | +| Required | `BuildEdge` literals | Add `output_visibility` to every literal. | | Review if enabled | Existing pool choices | Avoid dependency-only entries and conflicting action pools. | | Optional | Existing manifests | Omit `console` to retain ordinary scheduling; it defaults to false. | @@ -64,12 +64,14 @@ their input boundary. Rust callers that construct `Target` with a struct literal must add the new `description` field (set it to `None` or `Some(...)`) and the `console` field (set it to `false` or `true`). Callers that construct `BuildEdge` with a struct -literal must add `console` for the same reason: both types expose public fields -and neither is `#[non_exhaustive]`. Deserialized manifests remain compatible -because `console` defaults to `false`. Callers constructing `NinjaBuildRequest` -or `NinjaToolRequest` must replace `cli: &cli` with `options: &options`. -Callers that read `BuildGraph::targets` must use the graph accessors instead; -see +literal must add `output_visibility` for the same reason: both types expose +public fields and neither is `#[non_exhaustive]`. Use +`EdgeOutputVisibility::Buffered` for ordinary edges and `Immediate` when the +manifest's `console: true` behaviour is required. Deserialized manifests remain +compatible because `console` defaults to `false`. Callers constructing +`NinjaBuildRequest` or `NinjaToolRequest` must replace `cli: &cli` with +`options: &options`. Callers that read `BuildGraph::targets` must use the graph +accessors instead; see [Resolve build-graph outputs through the accessors](#resolve-build-graph-outputs-through-the-accessors). Every other addition is opt-in. @@ -658,32 +660,6 @@ for other byte sequences; `hash` and `digest` stay byte-oriented and accept any content. See the [users' guide](users-guide.md#configure-file-reading-limits) for the full policy and its diagnostics. -## At-a-glance changes - - -Table: documented v0.1.0 additions, including `netsuke help targets`, and their -impact - -| Area | Impact | Where to read more | -| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------ | -| Convenience wrappers | Unchanged. `run_ninja` and `run_ninja_tool` behave exactly as before, inheriting the process environment. | [Users' guide](users-guide.md) | -| Child environment | New opt-in `netsuke::runner::CommandEnv` carries additive variable overrides and an injected `PATH` for Ninja child processes. | [Developers' guide](developers-guide.md#ninja-invocation-with-an-explicit-environment) | -| Request types | New `netsuke::runner::NinjaBuildRequest` and `netsuke::runner::NinjaToolRequest` name the program, `NinjaProcessOptions`, build file, targets or tool, a child environment, and a required `stderr_mode: StderrMode` policy for the `*_with` run functions. | [Developers' guide](developers-guide.md#ninja-invocation-with-an-explicit-environment) | -| Cached CLI configuration API | Breaking for callers of the unstable Rust API: use the opt-in cached discovery flow with `ConfigEnvProvider`; `ConfigStdEnvProvider` supplies process-backed access. | [Developers' guide](developers-guide.md#cached-configuration-merge) | -| Timing output | Existing `VerboseTimingReporter::new` keeps its stderr sink; Rust callers can opt into an owned `Write + Send` sink with `with_writer`. | [Developers' guide](developers-guide.md#verbose-timing-sink) | -| Glob expansion | Parent-relative patterns such as `glob('../shared/*.h')` now expand. The Jinja helper rejects matched paths that are not portable unquoted shell words. Metadata checks use a capability rooted at the pattern's longest literal directory prefix; missing or non-directory prefixes return no matches, and unresolvable symlink matches are skipped. | [Users' guide](users-guide.md) and [ADR-0010](adrs/adr-0010-scope-the-glob-metadata-capability-to-the-literal-prefix.md) | -| Executable discovery | **Breaking:** default `which` and `command_available` no longer recursively discover workspace executables when `PATH` is empty or unset. Opt in with `cwd_mode='workspace-recursive'` only for trusted workspaces. | [Users' guide](users-guide.md#template-helpers) | -| Command recipes | On Windows, legacy scalar commands, lists, and scripts use Windows PowerShell by default; YAML command lists remain opt-in, ordered, and fail-fast. | [Windows legacy recipe contract](users-guide.md#windows-legacy-recipe-contract) | -| Ninja text escaping | Write shell dollars normally. In `command:` and `script:` recipes, `$in`, `$out`, `$ins`, and `$outs` are shell variables, so Ninja emits their dollars doubled, and the selected shell receives their ordinary single-dollar spelling. `{{ ins }}` and `{{ outs }}` are the only Netsuke path markers. A manifest that used script `$in` or `$out` for Netsuke path substitution must use `{{ ins }}` or `{{ outs }}` instead. Spaces in build and default-target paths are escaped for Ninja as a `$` followed by a space, so whitespace-containing outputs remain valid. Paths containing `\|` or control characters remain rejected. Ninja can escape paths containing `$` or colon, but Netsuke does not yet accept them. Emitted metadata is escaped separately: a literal dollar in a description, `depfile`, `deps`, or `pool` is doubled, so `depfile: $out.d` is written as `$$out.d` and Ninja reads the literal name. Newline, carriage-return, and NUL metadata values are rejected. | [Users' guide](users-guide.md#review-the-safety-boundary) | -| Manifest discovery | Optional target/action `description` values are shown by the new `netsuke help targets` command. Manifests without them and existing build output are unchanged. | [Users' guide](users-guide.md) | -| Serial dependencies | New opt-in `dependency_order: serial` runs an action or target's direct `deps` list in declaration order. | [Serial dependency ordering](users-guide.md#run-direct-dependencies-serially) | -| Graph export | New opt-in `netsuke --json help targets --edges` output exposes a versioned graph beside the existing catalogue; root-level `--json` must precede `help`; omitting `--edges` preserves current output, and manifests need no changes. | [Graph export reference](users-guide.md#export-dependency-edges-and-raw-recipes) | -| Fetch redirects | Every redirect destination is now evaluated against the network policy before it is requested, so a redirect can no longer reach a host, scheme, or address the policy refuses. Chains stop after five redirects, a repeated destination is refused as a loop, and URL credentials are removed when the origin changes. | [Users' guide](users-guide.md#network-fetch-policy) and [ADR-0023](adrs/adr-0023-revalidate-every-fetch-redirect.md) | -| Manifest environment access | New optional exact-name `env()` allow and block lists. Existing manifests retain default-allow behaviour when neither list is configured; an active allowlist enables default-deny and a block always wins. | [Users' guide](users-guide.md#control-manifest-environment-access) | -| File-reading filters | The `contents`, `linecount`, `hash`, and `digest` filters now read under one 8 MiB default byte budget; a symlink final component is rejected unless `follow_symlinks=true` opts in, while FIFOs and devices are rejected outright, and per-call `max_bytes` can only narrow the budget. | [Configure file reading limits](users-guide.md#configure-file-reading-limits) | -| Script shell variables | **Breaking:** `$in` and `$out` in `script:` recipes are shell variables, as they already were in `command:` recipes. Use `{{ ins }}` and `{{ outs }}` for Netsuke path substitution. | [Script shell variables](#replace-script-in-and-out-path-substitution) | -| Build graph | **Breaking for callers of the unstable Rust API:** each logical build edge is stored once and indexed by output through `EdgeId`; `BuildGraph::targets` is no longer public. | [Graph accessors](#resolve-build-graph-outputs-through-the-accessors) | - ## What does not need to change The convenience wrappers keep their child-process behaviour: the child inherits @@ -706,8 +682,9 @@ owned sink and completion-ordering contract. - [ ] Review the migration sections for manifest or API features in use. - [ ] Before opting into `console`, use Ninja 1.5 or later and check that the action does not request a conflicting custom pool or contention class. -- [ ] Add `console: false` or `console: true` to Rust `Target` and `BuildEdge` - struct literals. +- [ ] Add `console: false` or `console: true` to Rust `Target` literals. +- [ ] Add `output_visibility: EdgeOutputVisibility::Buffered` or `Immediate` + to Rust `BuildEdge` literals. ## Common errors and fixes diff --git a/src/graph_view/tests/support.rs b/src/graph_view/tests/support.rs index aebfbe6b3..caa911961 100644 --- a/src/graph_view/tests/support.rs +++ b/src/graph_view/tests/support.rs @@ -69,7 +69,7 @@ pub(super) fn add_edge(graph: &mut BuildGraph, fixture: EdgeFixture<'_>) { progress_label: None, phony: fixture.phony, always: fixture.always, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }; assert!( graph.insert_edge(edge).is_ok(), diff --git a/src/ir/cycle/participation_tests.rs b/src/ir/cycle/participation_tests.rs index a394d04fe..aa9ea1a97 100644 --- a/src/ir/cycle/participation_tests.rs +++ b/src/ir/cycle/participation_tests.rs @@ -38,6 +38,7 @@ fn build_edge(spec: EdgeSpec<'_>) -> BuildEdge { order_only_deps: paths(spec.order_only_deps), phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::Buffered, } } diff --git a/src/ir/cycle/property_tests/analyse_tests.rs b/src/ir/cycle/property_tests/analyse_tests.rs index 7ec486a38..99d47b1ff 100644 --- a/src/ir/cycle/property_tests/analyse_tests.rs +++ b/src/ir/cycle/property_tests/analyse_tests.rs @@ -127,7 +127,7 @@ fn analyse_resolves_non_first_output_alias_as_dependency() { progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }; graph .insert_edge(producer) diff --git a/src/ir/cycle/property_tests/issue322_property_tests.rs b/src/ir/cycle/property_tests/issue322_property_tests.rs index ab9752389..6f75214e7 100644 --- a/src/ir/cycle/property_tests/issue322_property_tests.rs +++ b/src/ir/cycle/property_tests/issue322_property_tests.rs @@ -51,7 +51,7 @@ fn build_edge(output: Utf8PathBuf) -> BuildEdge { progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), } } diff --git a/src/ir/cycle/property_tests/mod.rs b/src/ir/cycle/property_tests/mod.rs index 433c0fe61..841e92a0b 100644 --- a/src/ir/cycle/property_tests/mod.rs +++ b/src/ir/cycle/property_tests/mod.rs @@ -59,7 +59,7 @@ impl EdgeBuilder { progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), } } } diff --git a/src/ir/cycle/property_tests/participation_tests.rs b/src/ir/cycle/property_tests/participation_tests.rs index 1c1a09b48..dbcfcba94 100644 --- a/src/ir/cycle/property_tests/participation_tests.rs +++ b/src/ir/cycle/property_tests/participation_tests.rs @@ -319,6 +319,7 @@ fn build_edge(index: usize, counts: AliasCounts) -> BuildEdge { order_only_deps: Vec::new(), phony: false, always: false, + output_visibility: crate::ir::EdgeOutputVisibility::Buffered, } } diff --git a/src/ir/cycle/tests.rs b/src/ir/cycle/tests.rs index 033905041..382f1b3a5 100644 --- a/src/ir/cycle/tests.rs +++ b/src/ir/cycle/tests.rs @@ -22,7 +22,7 @@ fn build_edge(inputs: &[&str], implicit_deps: &[&str], output: &str) -> BuildEdg progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), } } struct MissingDepsCase<'a> { diff --git a/src/ir/cycle/verification.rs b/src/ir/cycle/verification.rs index 74fa8e00f..0c0a9e07b 100644 --- a/src/ir/cycle/verification.rs +++ b/src/ir/cycle/verification.rs @@ -198,7 +198,7 @@ fn edge_with(spec: EdgeSpec<'_>) -> BuildEdge { order_only_deps: spec.order_only_deps, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), } } diff --git a/src/ir/from_manifest/mod.rs b/src/ir/from_manifest/mod.rs index 814e75d90..83b44feda 100644 --- a/src/ir/from_manifest/mod.rs +++ b/src/ir/from_manifest/mod.rs @@ -16,7 +16,7 @@ use crate::recipe_shell::RecipeShell; use super::{ cycle::{self, CycleDetectionReport}, - graph::{BuildEdge, BuildGraph, DependencyOrder, IrGenError, IrHashMap}, + graph::{BuildEdge, BuildGraph, DependencyOrder, EdgeOutputVisibility, IrGenError, IrHashMap}, }; mod support; @@ -172,7 +172,11 @@ impl BuildGraph { order_only_deps: to_paths(&target.order_only_deps), phony: target.phony, always: target.always, - console: target.console, + output_visibility: if target.console { + EdgeOutputVisibility::Immediate + } else { + EdgeOutputVisibility::Buffered + }, }; #[cfg(not(kani))] diff --git a/src/ir/graph/error.rs b/src/ir/graph/error.rs index 09696770f..835019672 100644 --- a/src/ir/graph/error.rs +++ b/src/ir/graph/error.rs @@ -3,32 +3,10 @@ //! Keeps recoverable validation failures separate from the graph containers so //! callers can inspect stable IR diagnostics without coupling to storage. -use crate::localization::{self, LocalizedMessage, keys}; +use crate::localization::LocalizedMessage; use camino::Utf8PathBuf; use thiserror::Error; -/// Return the localized message for a recognized manifest validation detail. -/// -/// Manifest parsing and direct IR lowering share this message, while Ninja -/// generation uses output-specific diagnostics when a build edge is available. -/// -/// # Examples -/// -/// ```text -/// "console cannot be enabled for dependency-only entries" -/// -> localized "Dependency-only entries cannot select the console pool." -/// ``` -pub(crate) fn manifest_recipe_validation_message(detail: &'static str) -> Option { - (detail == crate::ast::CONSOLE_DEPENDENCY_ONLY_ERROR) - .then(|| localization::message(keys::MANIFEST_CONSOLE_DEPENDENCY_ONLY)) -} - -/// Render a manifest diagnostic in the active locale when available. -fn invalid_manifest_message(message: &'static str) -> String { - manifest_recipe_validation_message(message) - .map_or_else(|| message.to_owned(), |localized| localized.to_string()) -} - /// Errors produced during IR generation. /// /// Each variant documents a distinct validation failure encountered while @@ -61,7 +39,7 @@ fn invalid_manifest_message(message: &'static str) -> String { #[derive(Debug, Error)] pub enum IrGenError { /// Raised when a directly deserialized manifest violates recipe rules. - #[error("{}", invalid_manifest_message(.message))] + #[error("{message}")] InvalidManifest { /// Stable schema diagnostic identifying the violated recipe rule. message: &'static str, diff --git a/src/ir/graph/mod.rs b/src/ir/graph/mod.rs index 3f4e0e5a6..22184e3fc 100644 --- a/src/ir/graph/mod.rs +++ b/src/ir/graph/mod.rs @@ -268,6 +268,20 @@ pub struct Action { pub restat: bool, } +/// Desired visibility for output produced by one build edge. +/// +/// Backends translate this domain request into their own scheduling and +/// forwarding mechanisms. `Immediate` asks that output become visible as the +/// action produces it; `Buffered` keeps the backend's normal behaviour. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub enum EdgeOutputVisibility { + /// Use the backend's normal output buffering behaviour. + #[default] + Buffered, + /// Make output visible while the action is still running. + Immediate, +} + /// A single build statement connecting inputs to outputs. #[derive(Debug, Clone, PartialEq)] pub struct BuildEdge { @@ -296,11 +310,11 @@ pub struct BuildEdge { pub phony: bool, /// Run the command on every invocation regardless of timestamps. pub always: bool, - /// Run this executable edge in Ninja's serial console pool. + /// Output visibility requested for this edge. /// - /// Console selection belongs to an edge rather than its reusable action, - /// so actions shared by console and ordinary targets keep one identity. - pub console: bool, + /// Keeping the request on the edge lets targets share one action while + /// selecting different output behaviour. + pub output_visibility: EdgeOutputVisibility, } mod error; @@ -309,4 +323,3 @@ mod error; mod property_tests; pub use error::IrGenError; -pub(crate) use error::manifest_recipe_validation_message; diff --git a/src/ir/mod.rs b/src/ir/mod.rs index 83df9093d..05eb52a36 100644 --- a/src/ir/mod.rs +++ b/src/ir/mod.rs @@ -1,8 +1,8 @@ //! Intermediate Representation structures. //! //! This module defines the validated build graph Netsuke passes to Ninja -//! generation. Recipe and command data contain no Ninja syntax; the deliberate -//! [`BuildEdge::console`] annotation selects Ninja's console pool per edge. +//! generation. Recipe and command data contain no Ninja syntax; +//! [`EdgeOutputVisibility`] carries an edge's backend-neutral output request. //! //! # Examples //! @@ -35,5 +35,7 @@ mod from_manifest; mod graph; pub use cmd_interpolate::{INS_TOKEN, OUTS_TOKEN}; -pub(crate) use graph::manifest_recipe_validation_message; -pub use graph::{Action, BuildEdge, BuildGraph, DependencyOrder, EdgeArena, EdgeId, IrGenError}; +pub use graph::{ + Action, BuildEdge, BuildGraph, DependencyOrder, EdgeArena, EdgeId, EdgeOutputVisibility, + IrGenError, +}; diff --git a/src/manifest/mod.rs b/src/manifest/mod.rs index e024ea003..a7e0a976d 100644 --- a/src/manifest/mod.rs +++ b/src/manifest/mod.rs @@ -263,8 +263,11 @@ fn expand_and_render(request: ExpansionRequest<'_, '_, '_>) -> Result BuildEdge { progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), } } diff --git a/src/ninja_gen/determinism/property_tests/ninja_oracle/relay_regression.rs b/src/ninja_gen/determinism/property_tests/ninja_oracle/relay_regression.rs index 37af37c2e..e39848468 100644 --- a/src/ninja_gen/determinism/property_tests/ninja_oracle/relay_regression.rs +++ b/src/ninja_gen/determinism/property_tests/ninja_oracle/relay_regression.rs @@ -110,7 +110,7 @@ fn crash_shaped_graph() -> BuildGraph { progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }, BuildEdge { action_id: "aggregate".into(), @@ -123,7 +123,7 @@ fn crash_shaped_graph() -> BuildGraph { progress_label: None, phony: true, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }, BuildEdge { action_id: "aggregate".into(), @@ -136,7 +136,7 @@ fn crash_shaped_graph() -> BuildGraph { progress_label: None, phony: true, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }, BuildEdge { action_id: "aggregate".into(), @@ -149,7 +149,7 @@ fn crash_shaped_graph() -> BuildGraph { progress_label: None, phony: true, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }, ] { // A duplicate alias would silently change the graph this test exists to diff --git a/src/ninja_gen/determinism/property_tests/no_loss.rs b/src/ninja_gen/determinism/property_tests/no_loss.rs index 2408a3b6a..eb35b0e35 100644 --- a/src/ninja_gen/determinism/property_tests/no_loss.rs +++ b/src/ninja_gen/determinism/property_tests/no_loss.rs @@ -191,7 +191,7 @@ fn edge_with_explicit_outputs(outputs: &[&str]) -> BuildEdge { progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), } } diff --git a/src/ninja_gen/determinism/property_tests/order.rs b/src/ninja_gen/determinism/property_tests/order.rs index b650d117a..6012e29a6 100644 --- a/src/ninja_gen/determinism/property_tests/order.rs +++ b/src/ninja_gen/determinism/property_tests/order.rs @@ -328,7 +328,7 @@ fn the_edge_rule_keys_on_the_output_set() { progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }; let later = edge(["out/a", "out/z"]); let earlier = edge(["out/a", "out/m"]); diff --git a/src/ninja_gen/determinism/strategy/graph/blueprint.rs b/src/ninja_gen/determinism/strategy/graph/blueprint.rs index f6d010d0a..927cf4272 100644 --- a/src/ninja_gen/determinism/strategy/graph/blueprint.rs +++ b/src/ninja_gen/determinism/strategy/graph/blueprint.rs @@ -149,7 +149,7 @@ impl Blueprint { progress_label: None, phony: plan.is_phony, always: plan.is_always, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }); } edges diff --git a/src/ninja_gen/display_edge.rs b/src/ninja_gen/display_edge.rs index 704a2a1d9..babd6e457 100644 --- a/src/ninja_gen/display_edge.rs +++ b/src/ninja_gen/display_edge.rs @@ -41,7 +41,7 @@ impl Display for DisplayEdge<'_> { writeln!(output)?; write_flag!(output, "restat", self.edge.always && !self.action_restat); write_kv!(output, "description", self.description); - if self.edge.console { + if self.edge.output_visibility == crate::ir::EdgeOutputVisibility::Immediate { writeln!(output, " pool = console")?; } else if self.action_pool == Some("console") { // Rule bindings are inherited by edges, so clear a rule-level diff --git a/src/ninja_gen/dyndep/mod.rs b/src/ninja_gen/dyndep/mod.rs index ed2fbddb0..4f7cf6a19 100644 --- a/src/ninja_gen/dyndep/mod.rs +++ b/src/ninja_gen/dyndep/mod.rs @@ -54,7 +54,7 @@ //! progress_label: None, //! phony: false, //! always: false, -//! console: false, +//! output_visibility: netsuke::ir::EdgeOutputVisibility::default(), //! }).expect("test graph output aliases must be unique"); //! let bundle = generate_bundle(&graph).expect("generate bundle"); //! assert!(bundle.build_file().contains("ninja_required_version = 1.10")); @@ -174,7 +174,10 @@ fn generate_bundle_inner( Ok(GeneratedNinja { build_file: out, dyndep_files, - console_edge_count: graph.edges().filter(|edge| edge.console).count(), + console_edge_count: graph + .edges() + .filter(|edge| edge.output_visibility == crate::ir::EdgeOutputVisibility::Immediate) + .count(), }) } diff --git a/src/ninja_gen/dyndep/tests/console.rs b/src/ninja_gen/dyndep/tests/console.rs index cc7ed4564..e203619d3 100644 --- a/src/ninja_gen/dyndep/tests/console.rs +++ b/src/ninja_gen/dyndep/tests/console.rs @@ -1,7 +1,7 @@ //! Tests for console selection across staged serial dependency bundles. use super::{generate_bundle, graph_with_edge, serial_edge}; -use crate::ir::{BuildEdge, DependencyOrder}; +use crate::ir::{BuildEdge, DependencyOrder, EdgeOutputVisibility}; use anyhow::{Result, ensure}; use camino::Utf8PathBuf; @@ -10,7 +10,7 @@ use camino::Utf8PathBuf; #[test] fn console_serial_parent_does_not_mark_gates_or_sidecars() -> Result<()> { let mut edge = serial_edge("all", &["check-fmt", "lint"]); - edge.console = true; + edge.output_visibility = EdgeOutputVisibility::Immediate; let graph = graph_with_edge(edge); let bundle = generate_bundle(&graph)?; ensure!( @@ -73,7 +73,7 @@ fn console_serial_parent_does_not_mark_gates_or_sidecars() -> Result<()> { #[test] fn ordinary_dyndep_edge_clears_a_shared_console_action_pool() -> Result<()> { let mut selected = serial_edge("all", &["check-fmt", "lint"]); - selected.console = true; + selected.output_visibility = EdgeOutputVisibility::Immediate; let mut graph = graph_with_edge(selected); graph.actions.get_mut("a").expect("test action exists").pool = Some("console".into()); graph @@ -88,7 +88,7 @@ fn ordinary_dyndep_edge_clears_a_shared_console_action_pool() -> Result<()> { progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }) .expect("test graph output aliases must be unique"); diff --git a/src/ninja_gen/dyndep/tests/dependency_only.rs b/src/ninja_gen/dyndep/tests/dependency_only.rs index c10136976..676e16e7b 100644 --- a/src/ninja_gen/dyndep/tests/dependency_only.rs +++ b/src/ninja_gen/dyndep/tests/dependency_only.rs @@ -35,7 +35,7 @@ fn dependency_only_serial_bundle_uses_phony_without_a_recipe() -> Result<()> { progress_label: None, phony: true, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }) .expect("test graph output aliases must be unique"); diff --git a/src/ninja_gen/dyndep/tests/mod.rs b/src/ninja_gen/dyndep/tests/mod.rs index 514f6b370..8ecfac52b 100644 --- a/src/ninja_gen/dyndep/tests/mod.rs +++ b/src/ninja_gen/dyndep/tests/mod.rs @@ -33,7 +33,7 @@ fn serial_edge(output: &str, deps: &[&str]) -> BuildEdge { progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), } } diff --git a/src/ninja_gen/mod.rs b/src/ninja_gen/mod.rs index 4273f0874..4ba23f2e9 100644 --- a/src/ninja_gen/mod.rs +++ b/src/ninja_gen/mod.rs @@ -69,7 +69,7 @@ use order::{ordered_default_targets, ordered_edges}; /// # Examples /// ``` /// use netsuke::ast::{CommandIsolation, Recipe}; -/// use netsuke::ir::{Action, BuildEdge, BuildGraph}; +/// use netsuke::ir::{Action, BuildEdge, BuildGraph, EdgeOutputVisibility}; /// use camino::Utf8PathBuf; /// let mut graph = BuildGraph::default(); /// graph.actions.insert("a".into(), Action { @@ -86,7 +86,8 @@ use order::{ordered_default_targets, ordered_edges}; /// dependency_order: netsuke::ir::DependencyOrder::Parallel, /// explicit_outputs: vec![Utf8PathBuf::from("out")], /// implicit_outputs: Vec::new(), order_only_deps: Vec::new(), -/// progress_label: None, phony: false, always: false, console: false +/// progress_label: None, phony: false, always: false, +/// output_visibility: EdgeOutputVisibility::Buffered /// }).expect("test graph output aliases must be unique"); /// # let result: Result<(), netsuke::ninja_gen::NinjaGenError> = (|| { /// let text = netsuke::ninja_gen::generate(&graph)?; @@ -119,7 +120,7 @@ pub fn generate(graph: &BuildGraph) -> Result { /// # Examples /// ``` /// use netsuke::ast::{CommandIsolation, Recipe}; -/// use netsuke::ir::{Action, BuildEdge, BuildGraph}; +/// use netsuke::ir::{Action, BuildEdge, BuildGraph, EdgeOutputVisibility}; /// use camino::Utf8PathBuf; /// let mut graph = BuildGraph::default(); /// graph.actions.insert("a".into(), Action { @@ -136,7 +137,8 @@ pub fn generate(graph: &BuildGraph) -> Result { /// dependency_order: netsuke::ir::DependencyOrder::Parallel, /// explicit_outputs: vec![Utf8PathBuf::from("out")], /// implicit_outputs: Vec::new(), order_only_deps: Vec::new(), -/// progress_label: None, phony: false, always: false, console: false +/// progress_label: None, phony: false, always: false, +/// output_visibility: EdgeOutputVisibility::Buffered /// }).expect("test graph output aliases must be unique"); /// let mut out = String::new(); /// # let result: Result<(), netsuke::ninja_gen::NinjaGenError> = (|| { diff --git a/src/ninja_gen/progress_label.rs b/src/ninja_gen/progress_label.rs index ad53b279f..5401769ec 100644 --- a/src/ninja_gen/progress_label.rs +++ b/src/ninja_gen/progress_label.rs @@ -121,7 +121,7 @@ mod tests { progress_label: progress_label.map(str::to_owned), phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), } } diff --git a/src/ninja_gen/property_tests/console.rs b/src/ninja_gen/property_tests/console.rs index 46b34ec82..5d8090142 100644 --- a/src/ninja_gen/property_tests/console.rs +++ b/src/ninja_gen/property_tests/console.rs @@ -7,7 +7,7 @@ //! inherit console scheduling. use super::{aggregate_gates, generate_bundle, serial_graph}; -use crate::ir::{BuildGraph, DependencyOrder}; +use crate::ir::{BuildGraph, DependencyOrder, EdgeOutputVisibility}; use camino::{Utf8Path, Utf8PathBuf}; use proptest::prelude::*; use test_support::ninja_edge::edge_pool_bindings; @@ -55,7 +55,11 @@ fn generate_console_bundle( .target_for_output(Utf8Path::new("aggregate")) .map(|(_, edge)| edge.clone()) .ok_or_else(|| TestCaseError::fail("serial parent must exist"))?; - parent.console = parent_console; + parent.output_visibility = if parent_console { + EdgeOutputVisibility::Immediate + } else { + EdgeOutputVisibility::Buffered + }; if !graph.replace_edge_for_output(Utf8Path::new("aggregate"), parent.clone()) { return Err(TestCaseError::fail("serial parent must be replaceable")); } @@ -65,7 +69,11 @@ fn generate_console_bundle( edge.explicit_outputs = vec![output.clone()]; edge.implicit_deps.clear(); edge.dependency_order = DependencyOrder::Parallel; - edge.console = *selected; + edge.output_visibility = if *selected { + EdgeOutputVisibility::Immediate + } else { + EdgeOutputVisibility::Buffered + }; graph .insert_edge(edge) .map_err(|error| TestCaseError::fail(error.to_string()))?; diff --git a/src/ninja_gen/property_tests/dependency_only.rs b/src/ninja_gen/property_tests/dependency_only.rs index 33f18bfca..ec8671f1b 100644 --- a/src/ninja_gen/property_tests/dependency_only.rs +++ b/src/ninja_gen/property_tests/dependency_only.rs @@ -42,7 +42,7 @@ fn dependency_only_graph(dependencies: &[String], order: DependencyOrder) -> Bui progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }) .is_ok(), "test graph output aliases must be unique", diff --git a/src/ninja_gen/property_tests/edge_strategy.rs b/src/ninja_gen/property_tests/edge_strategy.rs index a5a0dcce8..f6cadda26 100644 --- a/src/ninja_gen/property_tests/edge_strategy.rs +++ b/src/ninja_gen/property_tests/edge_strategy.rs @@ -43,7 +43,7 @@ pub(super) fn edge_strategy_with_ranges( progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), } }, ) diff --git a/src/ninja_gen/property_tests/mod.rs b/src/ninja_gen/property_tests/mod.rs index 46fbdf639..fd4c2dc81 100644 --- a/src/ninja_gen/property_tests/mod.rs +++ b/src/ninja_gen/property_tests/mod.rs @@ -90,7 +90,7 @@ fn serial_graph(dependencies: Vec) -> BuildGraph { progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }) .is_ok(), "test graph output aliases must be unique", diff --git a/src/ninja_gen/property_tests/ninja_oracle.rs b/src/ninja_gen/property_tests/ninja_oracle.rs index 99409783e..96dbf7094 100644 --- a/src/ninja_gen/property_tests/ninja_oracle.rs +++ b/src/ninja_gen/property_tests/ninja_oracle.rs @@ -52,7 +52,7 @@ pub(super) fn scalar_graph(command: String) -> BuildGraph { progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }) .is_ok(), "test graph output aliases must be unique", diff --git a/src/ninja_gen/tests/console.rs b/src/ninja_gen/tests/console.rs index 85bb15f48..6a87a6fd4 100644 --- a/src/ninja_gen/tests/console.rs +++ b/src/ninja_gen/tests/console.rs @@ -2,7 +2,7 @@ use super::command_action; use super::{NinjaGenError, generate, generate_bundle, generate_posix}; -use crate::ir::{BuildEdge, BuildGraph, DependencyOrder}; +use crate::ir::{BuildEdge, BuildGraph, DependencyOrder, EdgeOutputVisibility}; use anyhow::{Result, ensure}; use camino::Utf8PathBuf; use rstest::{fixture, rstest}; @@ -23,7 +23,11 @@ fn edge_factory() -> EdgeFactory { progress_label: None, phony: false, always: false, - console, + output_visibility: if console { + EdgeOutputVisibility::Immediate + } else { + EdgeOutputVisibility::Buffered + }, } } diff --git a/src/ninja_gen/tests/mod.rs b/src/ninja_gen/tests/mod.rs index c20b72faf..08ca8bfad 100644 --- a/src/ninja_gen/tests/mod.rs +++ b/src/ninja_gen/tests/mod.rs @@ -37,7 +37,7 @@ fn generate_simple_ninja() -> Result<()> { progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("a".into(), action); @@ -74,7 +74,7 @@ fn string_generation_apis_reject_reserved_paths() -> Result<()> { progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("reserved".into(), action); @@ -176,7 +176,7 @@ fn generate_script_ninja_round_trips() -> Result<()> { progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("a".into(), action); @@ -213,7 +213,7 @@ fn generate_command_list_ninja_joins_a_fail_fast_chain() -> Result<()> { progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("a".into(), action); diff --git a/src/ninja_gen/validation.rs b/src/ninja_gen/validation.rs index 53cf79dc6..f0fb64aa3 100644 --- a/src/ninja_gen/validation.rs +++ b/src/ninja_gen/validation.rs @@ -13,7 +13,10 @@ use crate::localization::{self, keys}; /// Returns an error when a console edge uses a dependency-only recipe or /// conflicts with an action's custom pool. pub(super) fn validate_console_scheduling(graph: &BuildGraph) -> Result<(), NinjaGenError> { - for edge in graph.edges().filter(|edge| edge.console) { + for edge in graph + .edges() + .filter(|edge| edge.output_visibility == crate::ir::EdgeOutputVisibility::Immediate) + { if let Some(action) = graph.actions.get(&edge.action_id) { validate_console_edge(edge, action)?; } @@ -201,7 +204,7 @@ mod tests { progress_label: None, phony: false, always: false, - console: true, + output_visibility: crate::ir::EdgeOutputVisibility::Immediate, }) .expect("test graph output aliases must be unique"); graph diff --git a/src/runner/dyndep/generation_telemetry.rs b/src/runner/dyndep/generation_telemetry.rs index 7e555fd0a..37313e043 100644 --- a/src/runner/dyndep/generation_telemetry.rs +++ b/src/runner/dyndep/generation_telemetry.rs @@ -32,7 +32,10 @@ pub(in crate::runner) fn instrument_bundle_generation( // Count logical build operations once; `output_count` measures aliases and // would inflate this span for a single multi-output edge. let canonical_target_count = graph.edge_count(); - let console_edge_count = graph.edges().filter(|edge| edge.console).count(); + let console_edge_count = graph + .edges() + .filter(|edge| edge.output_visibility == crate::ir::EdgeOutputVisibility::Immediate) + .count(); let console_scheduling = crate::runner::console_telemetry::scheduling_label(console_edge_count); let span = tracing::trace_span!( "runner.ninja.dyndep_bundle.generate", @@ -248,7 +251,11 @@ mod tests { progress_label: None, phony: false, always: false, - console: index < console_edges, + output_visibility: if index < console_edges { + crate::ir::EdgeOutputVisibility::Immediate + } else { + crate::ir::EdgeOutputVisibility::Buffered + }, }) .expect("fixture aliases must be distinct"); } diff --git a/src/runner/process/streaming/tests/flush_tests.rs b/src/runner/process/streaming/tests/flush_tests.rs index da4c1bb95..43c1aa920 100644 --- a/src/runner/process/streaming/tests/flush_tests.rs +++ b/src/runner/process/streaming/tests/flush_tests.rs @@ -1,6 +1,7 @@ //! Verify each forwarded output chunk becomes visible before the next read. use super::{ChunkedReader, ForwardFailureKind, ForwardStream, forward_child_output}; +use proptest::prelude::*; use std::{ cell::RefCell, io::{self, Read, Write}, @@ -32,6 +33,7 @@ struct FlushBufferWriter { visible: Vec, snapshots: Vec>, maximum_write: usize, + write_count: usize, events: Rc>>, } @@ -47,6 +49,7 @@ impl FlushBufferWriter { impl Write for FlushBufferWriter { fn write(&mut self, bytes: &[u8]) -> io::Result { + self.write_count = self.write_count.saturating_add(1); let count = bytes.len().min(self.maximum_write); let Some(prefix) = bytes.get(..count) else { return Err(io::Error::other("writer prefix is outside the input")); @@ -63,6 +66,50 @@ impl Write for FlushBufferWriter { } } +proptest! { + #[test] + fn copy_and_flush_preserves_generated_chunks_and_flush_order( + chunks in prop::collection::vec( + prop::collection::vec(any::(), 1..20), + 0..9, + ), + maximum_write in 1_usize..9, + ) { + let input = chunks.iter().flatten().copied().collect::>(); + let chunk_sizes = chunks.iter().map(Vec::len).collect::>(); + let events = Rc::default(); + let mut reader = ObservedReader { + inner: ChunkedReader::new(input.clone(), chunk_sizes), + events: Rc::clone(&events), + }; + let mut writer = FlushBufferWriter::new(maximum_write, Rc::clone(&events)); + + let result = super::super::copy_and_flush(&mut reader, &mut writer); + prop_assert!(result.is_ok(), "copy_and_flush failed: {:?}", result.err()); + prop_assert_eq!(&writer.visible, &input); + + let mut visible = Vec::new(); + let expected_snapshots = chunks.iter().map(|chunk| { + visible.extend_from_slice(chunk); + visible.clone() + }).collect::>(); + prop_assert_eq!(&writer.snapshots, &expected_snapshots); + + let expected_write_count = chunks.iter() + .map(|chunk| chunk.len().div_ceil(maximum_write)) + .sum::(); + prop_assert_eq!(writer.write_count, expected_write_count); + + let mut expected_events = Vec::new(); + for chunk in &chunks { + expected_events.push(ForwardingEvent::Read(chunk.len())); + expected_events.push(ForwardingEvent::Flush); + } + expected_events.push(ForwardingEvent::Read(0)); + prop_assert_eq!(&*events.borrow(), &expected_events); + } +} + #[test] fn forward_output_flushes_each_complete_chunk_before_reading_more() { let input = b"firstlast".to_vec(); diff --git a/src/runner/process/test_fixtures.rs b/src/runner/process/test_fixtures.rs index fc43408e3..d665f8e25 100644 --- a/src/runner/process/test_fixtures.rs +++ b/src/runner/process/test_fixtures.rs @@ -49,7 +49,7 @@ pub(super) fn insert_edge( progress_label: None, phony, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }); assert!(result.is_ok(), "test edge outputs should be unique"); } diff --git a/src/runner/process/tests/command_list_targets.rs b/src/runner/process/tests/command_list_targets.rs index d30dfa3a7..453b0dbf8 100644 --- a/src/runner/process/tests/command_list_targets.rs +++ b/src/runner/process/tests/command_list_targets.rs @@ -84,7 +84,7 @@ fn implicit_output_is_used_when_an_edge_has_no_explicit_output( progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }); assert!(result.is_ok(), "test edge outputs should be unique"); diff --git a/src/runner/tests/mod.rs b/src/runner/tests/mod.rs index 82084b4eb..27c9ff49e 100644 --- a/src/runner/tests/mod.rs +++ b/src/runner/tests/mod.rs @@ -247,7 +247,7 @@ fn ninja_text_propagates_typed_generation_errors() { progress_label: None, phony: false, always: false, - console: false, + output_visibility: crate::ir::EdgeOutputVisibility::default(), }) .expect("test graph output aliases must be unique"); diff --git a/src/snapshots/manifest_load_options/from_path_with_limits.snap b/src/snapshots/manifest_load_options/from_path_with_limits.snap index 932dfadaf..0a375e82f 100644 --- a/src/snapshots/manifest_load_options/from_path_with_limits.snap +++ b/src/snapshots/manifest_load_options/from_path_with_limits.snap @@ -1,5 +1,6 @@ --- source: src/manifest/tests/wrapper_equivalence.rs +assertion_line: 146 expression: document --- === pure === @@ -16,6 +17,7 @@ expression: document }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -46,6 +48,7 @@ Manifest parse failed.: YAML parse error at line ⁨1⁩, column ⁨11⁩: ⁨un }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: True/False", diff --git a/tests/ast_tests/dependency_only.rs b/tests/ast_tests/dependency_only.rs index c08ede56b..e4e68f119 100644 --- a/tests/ast_tests/dependency_only.rs +++ b/tests/ast_tests/dependency_only.rs @@ -247,18 +247,20 @@ fn dependency_only_console_entries_are_rejected(#[case] sections: &str) -> Resul /// Preserve the console-specific validation diagnostic during direct IR lowering. #[test] fn direct_ir_lowering_preserves_console_dependency_only_diagnostic() -> Result<()> { - let _locale = locale_localizer("fr"); - let yaml = concat!( - "netsuke_version: '1.0.0'\n", - "targets:\n", - " - name: release\n", - " deps: [build]\n", - " console: true\n", - ); - let manifest: NetsukeManifest = - serde_saphyr::from_str(yaml).context("directly deserialize the manifest")?; - let error = BuildGraph::from_manifest(&manifest) - .expect_err("console scheduling on an aggregate should fail IR lowering"); + let error = { + let _locale = locale_localizer("en-US"); + let yaml = concat!( + "netsuke_version: '1.0.0'\n", + "targets:\n", + " - name: release\n", + " deps: [build]\n", + " console: true\n", + ); + let manifest: NetsukeManifest = + serde_saphyr::from_str(yaml).context("directly deserialize the manifest")?; + BuildGraph::from_manifest(&manifest) + .expect_err("console scheduling on an aggregate should fail IR lowering") + }; ensure!( matches!( &error, @@ -268,10 +270,15 @@ fn direct_ir_lowering_preserves_console_dependency_only_diagnostic() -> Result<( "direct AST lowering should preserve the console diagnostic code: {error}" ); let rendered = error.to_string(); - let expected = localization::message(keys::MANIFEST_CONSOLE_DEPENDENCY_ONLY).to_string(); + let expected = "console cannot be enabled for dependency-only entries"; ensure!( rendered == expected, - "direct AST lowering should render exactly the localized manifest diagnostic: {rendered}" + "direct AST lowering should render its stable diagnostic: {rendered}" + ); + let _locale = locale_localizer("fr"); + ensure!( + error.to_string() == expected, + "IR diagnostic rendering must not depend on the active locale" ); Ok(()) } diff --git a/tests/bdd/steps/ninja.rs b/tests/bdd/steps/ninja.rs index 08265161f..a16ed44eb 100644 --- a/tests/bdd/steps/ninja.rs +++ b/tests/bdd/steps/ninja.rs @@ -54,7 +54,7 @@ fn dependency_only_console_graph(world: &TestWorld) -> Result<()> { progress_label: None, phony: true, always: false, - console: true, + output_visibility: netsuke::ir::EdgeOutputVisibility::Immediate, }) .context("insert dependency-only console edge")?; world.build_graph.set_value(graph); diff --git a/tests/ir_tests.rs b/tests/ir_tests.rs index 163e2ca0d..e7c8ac57e 100644 --- a/tests/ir_tests.rs +++ b/tests/ir_tests.rs @@ -7,7 +7,7 @@ use camino::Utf8PathBuf; use netsuke::ast::Recipe; -use netsuke::ir::{Action, BuildEdge, BuildGraph, IrGenError}; +use netsuke::ir::{Action, BuildEdge, BuildGraph, EdgeOutputVisibility, IrGenError}; use rstest::rstest; #[rstest] @@ -43,7 +43,7 @@ fn create_action_and_edge() { progress_label: None, phony: false, always: true, - console: false, + output_visibility: EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("id".into(), action); @@ -108,7 +108,7 @@ fn build_graph_indexes_canonical_targets() { progress_label: None, phony: false, always: false, - console: false, + output_visibility: EdgeOutputVisibility::default(), }; let edge_id = graph .insert_edge(edge1) @@ -143,7 +143,7 @@ fn build_graph_rejects_duplicate_aliases_before_mutation() { progress_label: None, phony: false, always: false, - console: false, + output_visibility: EdgeOutputVisibility::default(), }; let result = graph.insert_edge(edge); diff --git a/tests/ninja_dollar_escaping_tests.rs b/tests/ninja_dollar_escaping_tests.rs index 3dbe1ee9c..d5b0fad41 100644 --- a/tests/ninja_dollar_escaping_tests.rs +++ b/tests/ninja_dollar_escaping_tests.rs @@ -67,7 +67,7 @@ fn graph(recipe: Recipe, input: &str, output: &str) -> Result { progress_label: None, phony: false, always: false, - console: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("action".into(), action(recipe)); diff --git a/tests/ninja_gen_command_list_integration_tests.rs b/tests/ninja_gen_command_list_integration_tests.rs index 43de244f0..7859f6b53 100644 --- a/tests/ninja_gen_command_list_integration_tests.rs +++ b/tests/ninja_gen_command_list_integration_tests.rs @@ -10,7 +10,7 @@ use anyhow::{Context, Result, ensure}; use camino::Utf8PathBuf; use cap_std::{ambient_authority, fs_utf8::Dir}; use netsuke::ast::{CommandIsolation, Recipe, StringOrList}; -use netsuke::ir::{Action, BuildEdge, BuildGraph, DependencyOrder}; +use netsuke::ir::{Action, BuildEdge, BuildGraph, DependencyOrder, EdgeOutputVisibility}; use netsuke::ninja_gen::{NinjaGenError, generate}; use std::process::Command; use tempfile::TempDir; @@ -36,7 +36,7 @@ fn run_command_list( progress_label: None, phony: false, always: false, - console: false, + output_visibility: EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("chain".into(), action); diff --git a/tests/ninja_gen_command_list_process_integration_tests.rs b/tests/ninja_gen_command_list_process_integration_tests.rs index f910a138f..a0650bf27 100644 --- a/tests/ninja_gen_command_list_process_integration_tests.rs +++ b/tests/ninja_gen_command_list_process_integration_tests.rs @@ -77,7 +77,7 @@ fn run_command_list_with_progress( progress_label: progress_label.map(str::to_owned), phony: false, always: false, - console: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("chain".into(), action); diff --git a/tests/ninja_gen_inline_progress_process_tests.rs b/tests/ninja_gen_inline_progress_process_tests.rs index a8d561129..1a8f8619c 100644 --- a/tests/ninja_gen_inline_progress_process_tests.rs +++ b/tests/ninja_gen_inline_progress_process_tests.rs @@ -6,7 +6,7 @@ use anyhow::{Context, Result, ensure}; use camino::Utf8PathBuf; use cap_std::{ambient_authority, fs_utf8::Dir}; use netsuke::ast::{CommandIsolation, Recipe, StringOrList}; -use netsuke::ir::{Action, BuildEdge, BuildGraph, DependencyOrder}; +use netsuke::ir::{Action, BuildEdge, BuildGraph, DependencyOrder, EdgeOutputVisibility}; use netsuke::ninja_gen::generate; use rstest::{fixture, rstest}; use std::process::Command; @@ -54,7 +54,7 @@ fn run_action( progress_label: progress_label.map(str::to_owned), phony: false, always: false, - console: false, + output_visibility: EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert(edge.action_id.clone(), action); diff --git a/tests/ninja_gen_integration_tests.rs b/tests/ninja_gen_integration_tests.rs index c506fb0c5..846dd405b 100644 --- a/tests/ninja_gen_integration_tests.rs +++ b/tests/ninja_gen_integration_tests.rs @@ -54,7 +54,7 @@ fn ninja_integration_setup() -> Option { progress_label: None, phony: false, always: false, - console: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, target_name: Utf8PathBuf::from("out"), ninja_args: vec!["-n"], @@ -80,7 +80,7 @@ fn ninja_integration_setup() -> Option { progress_label: None, phony: false, always: false, - console: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, target_name: Utf8PathBuf::from("out"), ninja_args: vec!["out"], @@ -106,7 +106,7 @@ fn ninja_integration_setup() -> Option { progress_label: None, phony: false, always: false, - console: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, target_name: Utf8PathBuf::from("out"), ninja_args: vec!["out"], @@ -132,7 +132,7 @@ fn ninja_integration_setup() -> Option { progress_label: None, phony: true, always: false, - console: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, target_name: Utf8PathBuf::from("action-called.txt"), ninja_args: vec!["say-hello"], @@ -228,7 +228,7 @@ fn errors_when_action_missing() -> Result<()> { progress_label: None, phony: false, always: false, - console: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }; graph .insert_edge(edge) @@ -276,7 +276,7 @@ fn generate_format_error() -> Result<()> { progress_label: None, phony: false, always: false, - console: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("a".into(), action); @@ -318,7 +318,7 @@ fn serial_graph(command: &str, dependencies: &[&str]) -> Result { progress_label: None, phony: false, always: false, - console: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert("a".into(), action); diff --git a/tests/ninja_gen_integration_tests/progress_localization.rs b/tests/ninja_gen_integration_tests/progress_localization.rs index 1df88c9da..5590026f2 100644 --- a/tests/ninja_gen_integration_tests/progress_localization.rs +++ b/tests/ninja_gen_integration_tests/progress_localization.rs @@ -104,7 +104,7 @@ fn arabic_generation_preserves_fluent_isolates_and_sanitizes_input() -> Result<( progress_label: Some("left\u{202e}right".into()), phony: false, always: false, - console: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }; let mut graph = BuildGraph::default(); graph.actions.insert(edge.action_id.clone(), action); diff --git a/tests/ninja_gen_unit_tests.rs b/tests/ninja_gen_unit_tests.rs index 5bd71584f..e4eb771d0 100644 --- a/tests/ninja_gen_unit_tests.rs +++ b/tests/ninja_gen_unit_tests.rs @@ -37,7 +37,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: true, always: false, - console: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, concat!( "rule a\n", @@ -65,7 +65,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: false, always: false, - console: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, concat!( "rule compile\n", @@ -93,7 +93,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: false, always: false, - console: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, concat!( "rule b\n", @@ -121,7 +121,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: false, always: false, - console: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, concat!( "rule b\n", @@ -149,7 +149,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: false, always: false, - console: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, concat!( "rule b\n", @@ -177,7 +177,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: false, always: false, - console: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, concat!( "rule b\n", @@ -205,7 +205,7 @@ fn generate_posix(graph: &BuildGraph) -> Result { progress_label: None, phony: true, always: false, - console: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }, concat!( "rule phony\n", @@ -266,7 +266,7 @@ fn generate_multiline_script_snapshot() { progress_label: None, phony: false, always: false, - console: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), }) .expect("test graph output aliases must be unique"); graph.default_targets.push(Utf8PathBuf::from("out")); diff --git a/tests/serial_dependency_runtime_tests.rs b/tests/serial_dependency_runtime_tests.rs index ba4c99158..903f94a9d 100644 --- a/tests/serial_dependency_runtime_tests.rs +++ b/tests/serial_dependency_runtime_tests.rs @@ -50,7 +50,7 @@ fn edge( progress_label: None, phony: false, always: false, - console: false, + output_visibility: netsuke::ir::EdgeOutputVisibility::default(), } } diff --git a/tests/snapshots/manifest_load_options/from_path.snap b/tests/snapshots/manifest_load_options/from_path.snap index 1a9a28e8b..698c2e790 100644 --- a/tests/snapshots/manifest_load_options/from_path.snap +++ b/tests/snapshots/manifest_load_options/from_path.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/path_rows.rs +assertion_line: 62 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -49,6 +51,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", @@ -80,6 +83,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, @@ -98,6 +102,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, diff --git a/tests/snapshots/manifest_load_options/from_path_with_policy.snap b/tests/snapshots/manifest_load_options/from_path_with_policy.snap index 109d72203..00492487e 100644 --- a/tests/snapshots/manifest_load_options/from_path_with_policy.snap +++ b/tests/snapshots/manifest_load_options/from_path_with_policy.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/path_rows.rs +assertion_line: 62 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -49,6 +51,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", @@ -80,6 +83,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, @@ -98,6 +102,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, diff --git a/tests/snapshots/manifest_load_options/from_path_with_policy_and_env.snap b/tests/snapshots/manifest_load_options/from_path_with_policy_and_env.snap index 3d9454f12..884b36ffc 100644 --- a/tests/snapshots/manifest_load_options/from_path_with_policy_and_env.snap +++ b/tests/snapshots/manifest_load_options/from_path_with_policy_and_env.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/path_rows.rs +assertion_line: 62 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -45,6 +47,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "profile characterization", @@ -77,6 +80,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", @@ -108,6 +112,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, @@ -126,6 +131,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, diff --git a/tests/snapshots/manifest_load_options/from_path_with_policy_and_env_and_limits.snap b/tests/snapshots/manifest_load_options/from_path_with_policy_and_env_and_limits.snap index 3d9454f12..884b36ffc 100644 --- a/tests/snapshots/manifest_load_options/from_path_with_policy_and_env_and_limits.snap +++ b/tests/snapshots/manifest_load_options/from_path_with_policy_and_env_and_limits.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/path_rows.rs +assertion_line: 62 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -45,6 +47,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "profile characterization", @@ -77,6 +80,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", @@ -108,6 +112,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, @@ -126,6 +131,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, diff --git a/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment.snap b/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment.snap index 3d9454f12..884b36ffc 100644 --- a/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment.snap +++ b/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/path_rows.rs +assertion_line: 62 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -45,6 +47,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "profile characterization", @@ -77,6 +80,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", @@ -108,6 +112,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, @@ -126,6 +131,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, diff --git a/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment_and_limits.snap b/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment_and_limits.snap index 3d9454f12..884b36ffc 100644 --- a/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment_and_limits.snap +++ b/tests/snapshots/manifest_load_options/from_path_with_policy_and_environment_and_limits.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/path_rows.rs +assertion_line: 62 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -45,6 +47,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "profile characterization", @@ -77,6 +80,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", @@ -108,6 +112,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, @@ -126,6 +131,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, diff --git a/tests/snapshots/manifest_load_options/from_path_with_policy_and_limits.snap b/tests/snapshots/manifest_load_options/from_path_with_policy_and_limits.snap index 109d72203..00492487e 100644 --- a/tests/snapshots/manifest_load_options/from_path_with_policy_and_limits.snap +++ b/tests/snapshots/manifest_load_options/from_path_with_policy_and_limits.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/path_rows.rs +assertion_line: 62 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -49,6 +51,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", @@ -80,6 +83,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, @@ -98,6 +102,7 @@ observer: ManifestIngestion, InitialYamlParsing, TemplateExpansion, FinalRenderi }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": null, diff --git a/tests/snapshots/manifest_load_options/from_str.snap b/tests/snapshots/manifest_load_options/from_str.snap index 83da27e17..f38314357 100644 --- a/tests/snapshots/manifest_load_options/from_str.snap +++ b/tests/snapshots/manifest_load_options/from_str.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/mod.rs +assertion_line: 170 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -49,6 +51,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", diff --git a/tests/snapshots/manifest_load_options/from_str_with_env.snap b/tests/snapshots/manifest_load_options/from_str_with_env.snap index 430f05a6f..9c1729aac 100644 --- a/tests/snapshots/manifest_load_options/from_str_with_env.snap +++ b/tests/snapshots/manifest_load_options/from_str_with_env.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/mod.rs +assertion_line: 179 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -45,6 +47,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "profile characterization", @@ -77,6 +80,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", diff --git a/tests/snapshots/manifest_load_options/from_str_with_env_and_config.snap b/tests/snapshots/manifest_load_options/from_str_with_env_and_config.snap index f30a02eeb..fb488d001 100644 --- a/tests/snapshots/manifest_load_options/from_str_with_env_and_config.snap +++ b/tests/snapshots/manifest_load_options/from_str_with_env_and_config.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/mod.rs +assertion_line: 200 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -45,6 +47,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "profile characterization", @@ -77,6 +80,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", @@ -108,6 +112,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "started 2026-06-08T12:00:00Z", diff --git a/tests/snapshots/manifest_load_options/from_str_with_env_and_policy.snap b/tests/snapshots/manifest_load_options/from_str_with_env_and_policy.snap index 430f05a6f..d23fd8e59 100644 --- a/tests/snapshots/manifest_load_options/from_str_with_env_and_policy.snap +++ b/tests/snapshots/manifest_load_options/from_str_with_env_and_policy.snap @@ -1,5 +1,6 @@ --- -source: tests/manifest_load_options/wrapper_equivalence.rs +source: tests/manifest_load_options/wrapper_equivalence/mod.rs +assertion_line: 189 expression: document --- === pure === @@ -17,6 +18,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "joined a-b", @@ -45,6 +47,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "profile characterization", @@ -77,6 +80,7 @@ observer: none accepted by this entry point }, "always": false, "conditional": false, + "console": false, "dependency_order": "parallel", "deps": null, "description": "literals: true/false", diff --git a/tests/ui/build_edge_console_compile_pass.rs b/tests/ui/build_edge_console_compile_pass.rs index 470fca50b..91eb8c63c 100644 --- a/tests/ui/build_edge_console_compile_pass.rs +++ b/tests/ui/build_edge_console_compile_pass.rs @@ -1,4 +1,4 @@ -//! Compile the public console scheduling flag as an external crate would. +//! Compile the public output visibility request as an external crate would. use netsuke::ast::{ CommandIsolation, DependencyOrder as AstDependencyOrder, Recipe, StringOrList, Target, @@ -6,7 +6,7 @@ use netsuke::ast::{ use netsuke::ir::{BuildEdge, DependencyOrder as IrDependencyOrder}; use std::collections::HashMap; -/// Construct a public build edge that opts into Ninja's console pool. +/// Construct a public build edge that requests immediate output visibility. #[must_use] pub fn console_edge() -> BuildEdge { BuildEdge { @@ -20,7 +20,7 @@ pub fn console_edge() -> BuildEdge { progress_label: None, phony: false, always: false, - console: true, + output_visibility: netsuke::ir::EdgeOutputVisibility::Immediate, } } From 2aa37d9bdb9be3fe7f2d52440e5f786dee068866 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 4 Nov 2026 11:31:42 +0100 Subject: [PATCH 25/27] Share the console pool name in Ninja generation (#946) Use one generator constant in edge rendering and console validation so both paths agree on the built-in pool name. Document its narrow scope. --- docs/developers-guide.md | 4 ++++ src/ninja_gen/display_edge.rs | 5 +++-- src/ninja_gen/mod.rs | 3 +++ src/ninja_gen/validation.rs | 4 ++-- 4 files changed, 12 insertions(+), 4 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index b2ba5ab21..43ba50c05 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -6516,6 +6516,10 @@ dependency-only edges and custom action pools that conflict with `console`; it accepts an existing `Action.pool` value of `console`. Synthetic serial gates and dyndep sidecars never inherit either binding. +`ninja_gen::CONSOLE_POOL` is the single production spelling of Ninja's built-in +pool. Keep its use within the edge renderer and shared console-scheduling +validator; tests continue to assert the emitted `console` value directly. + The emitted `pool = console` binding requires Ninja 1.5 or newer. This minimum is separate from the conditional Ninja 1.10 requirement for staged serial dependency bundles described below. diff --git a/src/ninja_gen/display_edge.rs b/src/ninja_gen/display_edge.rs index babd6e457..c2aa54f89 100644 --- a/src/ninja_gen/display_edge.rs +++ b/src/ninja_gen/display_edge.rs @@ -1,5 +1,6 @@ //! Ninja build-edge display rendering. +use super::CONSOLE_POOL; use super::escape::NinjaValue; use super::join; use crate::ir::BuildEdge; @@ -42,8 +43,8 @@ impl Display for DisplayEdge<'_> { write_flag!(output, "restat", self.edge.always && !self.action_restat); write_kv!(output, "description", self.description); if self.edge.output_visibility == crate::ir::EdgeOutputVisibility::Immediate { - writeln!(output, " pool = console")?; - } else if self.action_pool == Some("console") { + writeln!(output, " pool = {CONSOLE_POOL}")?; + } else if self.action_pool == Some(CONSOLE_POOL) { // Rule bindings are inherited by edges, so clear a rule-level // console pool on edges that did not opt in themselves. writeln!(output, " pool =")?; diff --git a/src/ninja_gen/mod.rs b/src/ninja_gen/mod.rs index 4ba23f2e9..ef304b15a 100644 --- a/src/ninja_gen/mod.rs +++ b/src/ninja_gen/mod.rs @@ -56,6 +56,9 @@ macro_rules! write_flag { }; } +/// Name Ninja's built-in pool shared by edge rendering and validation. +const CONSOLE_POOL: &str = "console"; + mod display_edge; pub(crate) use display_edge::DisplayEdge; mod edge_metadata; diff --git a/src/ninja_gen/validation.rs b/src/ninja_gen/validation.rs index f0fb64aa3..a781f6293 100644 --- a/src/ninja_gen/validation.rs +++ b/src/ninja_gen/validation.rs @@ -1,7 +1,7 @@ //! Validate recipes and metadata before Ninja rendering. use super::command_list::{CommandListEntry, CommandListEntryError, command_list_entry_error}; -use super::{NinjaGenError, RecipeShell, escape::validate_ninja_value}; +use super::{CONSOLE_POOL, NinjaGenError, RecipeShell, escape::validate_ninja_value}; use crate::ast::{CommandIsolation, Recipe, StringOrList}; use crate::ir::{Action, BuildEdge, BuildGraph}; use crate::localization::{self, keys}; @@ -44,7 +44,7 @@ fn validate_console_edge(edge: &BuildEdge, action: &Action) -> Result<(), NinjaG } if let Some(pool) = action.pool.as_deref() { validate_ninja_value(pool)?; - if pool != "console" { + if pool != CONSOLE_POOL { return Err(NinjaGenError::ConsolePoolConflict { message: localization::message(keys::NINJA_GEN_CONSOLE_POOL_CONFLICT) .with_arg("output", &output) From d2ded9f38c5d626f6dbd34c4cef05d5cf49408a4 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 4 Nov 2026 12:51:12 +0100 Subject: [PATCH 26/27] Clarify console pool guidance and pt-PT terms (#946) Limit manifest-facing guidance to the supported `console` field and scope custom-pool migration notes to Rust callers that set `Action.pool`. Record `pool` as the pt-PT Ninja term and use it consistently in all console-pool diagnostics. --- docs/localization-glossary.md | 6 ++++-- docs/users-guide.md | 4 +--- docs/v0-1-0-migration-guide.md | 30 +++++++++++++++--------------- locales/pt-PT/messages.ftl | 6 +++--- 4 files changed, 23 insertions(+), 23 deletions(-) diff --git a/docs/localization-glossary.md b/docs/localization-glossary.md index 7f19b9d13..0a1f08170 100644 --- a/docs/localization-glossary.md +++ b/docs/localization-glossary.md @@ -2172,8 +2172,9 @@ gerund, e.g. "A compilar…" for "Building…". Several Netsuke terms stay as English loanwords in European Portuguese technical prose: `cache` (feminine, written in italics per Priberam and the -[translatewiki.net Portuguese cheatsheet](https://translatewiki.net/wiki/Portal:Pt/Cheatsheet/pt)) -and `macro` (feminine, unchanged). Product and syntax identifiers (Netsuke, +[translatewiki.net Portuguese cheatsheet](https://translatewiki.net/wiki/Portal:Pt/Cheatsheet/pt)), +`macro` (feminine, unchanged), and Ninja's `pool` (masculine; use "pool da +consola" and "pool personalizado"). Product and syntax identifiers (Netsuke, Ninja, Fluent, Jinja, YAML, `stdout`, `stderr`, `dyndep`) are invariant and carry no diacritics or gender marking. @@ -2211,6 +2212,7 @@ Table 30: Portuguese, Portugal terminology | action | ação | AO90 spelling (not "acção"); distinct word from "alvo" and "regra" per Netsuke's three-way distinction. | | rule | regra | Matches the [GNU Make Portuguese manual](https://www.ecoop.net/coop/translated/GNUMake4.4/ch09.pt.html) ("regra"). | | dependency | dependência | Standard software term. | +| pool | pool | Retain Ninja's English scheduling term; use "pool da consola" and "pool personalizado". | | order-only dependency | dependência apenas de ordem | Compositional; no established Portuguese precedent found for this Ninja-specific concept. | | build (noun) | compilação | Use "build" as an unmarked loan only when referring to a specific numbered build artefact, not the process. | | build (verb) | compilar | "Gerar" is acceptable when the build does not compile source code, mirroring the same ambiguity in English. | diff --git a/docs/users-guide.md b/docs/users-guide.md index 9368acc2d..87469337f 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -611,9 +611,7 @@ A target supports these fields: - `always`: forces the recipe to run whenever the target is requested. - `console`: selects Ninja's serial console pool for this executable action or target. It defaults to `false` and is unavailable to dependency-only - aggregates. Netsuke rejects a conflicting custom action pool; an action pool - already named `console` is accepted for a console edge. Other edges sharing - that rule have the inherited console pool cleared. + aggregates. - `description`: an optional human-readable summary of the public operation the target performs. It is discovery metadata shown by `netsuke help targets`; it never replaces a referenced rule's `description` diff --git a/docs/v0-1-0-migration-guide.md b/docs/v0-1-0-migration-guide.md index 9ab2a1e1f..2dd1df4d9 100644 --- a/docs/v0-1-0-migration-guide.md +++ b/docs/v0-1-0-migration-guide.md @@ -9,15 +9,15 @@ sections that match the features they use. ## Impact at a glance -| Priority | Area | What to do | -| ----------------- | ---------------------- | ------------------------------------------------------------------- | -| Required | Checkout source builds | Use the pinned Rust toolchain before building from a checkout. | -| Required | Linux checkout builds | Install the pinned `mold` linker before building from a checkout. | -| Review if enabled | Manifest scheduling | Opt in only where immediate console output is useful. | -| Required | `Target` literals | Add `description` and `console` to every literal. | -| Required | `BuildEdge` literals | Add `output_visibility` to every literal. | -| Review if enabled | Existing pool choices | Avoid dependency-only entries and conflicting action pools. | -| Optional | Existing manifests | Omit `console` to retain ordinary scheduling; it defaults to false. | +| Priority | Area | What to do | +| ----------------- | -------------------------- | ------------------------------------------------------------------- | +| Required | Checkout source builds | Use the pinned Rust toolchain before building from a checkout. | +| Required | Linux checkout builds | Install the pinned `mold` linker before building from a checkout. | +| Review if enabled | Manifest scheduling | Opt in only where immediate console output is useful. | +| Required | `Target` literals | Add `description` and `console` to every literal. | +| Required | `BuildEdge` literals | Add `output_visibility` to every literal. | +| Review if enabled | Rust `Action.pool` choices | Check custom pool values for conflicts with `console`. | +| Optional | Existing manifests | Omit `console` to retain ordinary scheduling; it defaults to false. | _Table 1: migration obligations for the beta series and console-pool option._ @@ -680,8 +680,7 @@ owned sink and completion-ordering contract. - [ ] Install the pinned Rust toolchain before building from a checkout. - [ ] On Linux, install the pinned linker before the first source build. - [ ] Review the migration sections for manifest or API features in use. -- [ ] Before opting into `console`, use Ninja 1.5 or later and check that the - action does not request a conflicting custom pool or contention class. +- [ ] Before opting into `console`, use Ninja 1.5 or later. - [ ] Add `console: false` or `console: true` to Rust `Target` literals. - [ ] Add `output_visibility: EdgeOutputVisibility::Buffered` or `Immediate` to Rust `BuildEdge` literals. @@ -693,10 +692,11 @@ owned sink and completion-ordering contract. - **Error:** `Build edge 'build/stamp' cannot select the console pool because its action is dependency-only.` - **Fix:** Enable console scheduling only on an executable action or target. -- **Error:** `Build edge 'build/stamp' selects the console pool but its action - requests custom pool 'compile'.` - - **Fix:** Remove the conflicting action pool, or select `console` there only - when the action itself should use the console pool. +- **Rust API error (`Action.pool`):** + `Build edge 'build/stamp' selects the console pool but its action requests + custom pool 'compile'.` + - **Fix:** For Rust callers, remove the conflicting `Action.pool` value or + set it to `console` when that edge should use the console pool. ## Diagnostics diff --git a/locales/pt-PT/messages.ftl b/locales/pt-PT/messages.ftl index dfd4db298..2ff457123 100644 --- a/locales/pt-PT/messages.ftl +++ b/locales/pt-PT/messages.ftl @@ -187,7 +187,7 @@ manifest.glob.unknown_io_error = erro de E/S desconhecido. manifest.command_list_empty = O campo «command» não pode estar vazio: forneça uma cadeia de comando ou uma lista não vazia. manifest.command_isolation_recipe = O campo «command_isolation» exige uma receita com uma lista de comandos. manifest.command_isolation_scalar = «command_isolation: isolated» exige uma lista de comandos. -manifest.console_dependency_only = Entradas que declaram apenas dependências não podem selecionar o conjunto da consola. +manifest.console_dependency_only = Entradas que declaram apenas dependências não podem selecionar o pool da consola. # Erros da representação intermédia. ir.rule_not_found = A regra «{ $rule }» referenciada pelo alvo «{ $target }» não foi encontrada. @@ -200,8 +200,8 @@ ir.invalid_command = Interpolação de comando inválida: { $snippet }. # Erros de geração do Ninja. ninja_gen.missing_action = Falta a ação «{ $id }» referenciada por uma aresta de compilação. -ninja_gen.console_dependency_only = A aresta de compilação «{ $output }» não pode selecionar o conjunto da consola porque a sua ação só declara dependências. -ninja_gen.console_pool_conflict = A aresta de compilação «{ $output }» seleciona o conjunto da consola, mas a sua ação pede o conjunto personalizado «{ $pool }». +ninja_gen.console_dependency_only = A aresta de compilação «{ $output }» não pode selecionar o pool da consola porque a sua ação só declara dependências. +ninja_gen.console_pool_conflict = A aresta de compilação «{ $output }» seleciona o pool da consola, mas a sua ação pede o pool personalizado «{ $pool }». ninja_gen.format = Não foi possível formatar a saída do manifesto Ninja. ninja_gen.dyndep_files_required = Esta operação requer um pacote Ninja gerado; utilize `netsuke build`, `netsuke clean` ou `netsuke generate` para materializar os ficheiros dyndep. ninja_gen.reserved_output_path = O caminho '{ $path }' está reservado para o estado de dependências seriais do Netsuke. From 75ea5c76ecb0c290f009544887dbfa10abd7345c Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 4 Nov 2026 13:09:43 +0100 Subject: [PATCH 27/27] Document console pool property checks (#946) Document the property test's user-edge, serial-parent, and synthetic-gate assertions that use `edge_pool_bindings`. Keep the helper's test-only call-site boundary explicit while closing the Developer Documentation warning. --- docs/developers-guide.md | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 43ba50c05..615807ede 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -7359,8 +7359,10 @@ not depend on `test_support`. `test_support::ninja_edge::edge_pool_bindings` returns the named edge's exact `pool =` bindings. It distinguishes `pool = console` for an opted-in edge, an empty `pool =` binding that clears an inherited console pool, and no binding -for an ordinary edge. Keep this helper within test-support and test code; it -does not parse general Ninja syntax. +for an ordinary edge. The property test in +`src/ninja_gen/property_tests/console.rs` uses it for user-edge, serial-parent +and synthetic-gate assertions. Keep this helper within test-support and test +code; it does not parse general Ninja syntax. The local `edge_factory` fixture in `src/ninja_gen/tests/console.rs` constructs parallel, single-output edges for console generator tests. Keep it within that