From cf7e20062166a69f343b79f2bb7c9826b9929eaf Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 10 Aug 2026 13:01:23 +0200 Subject: [PATCH 01/69] Ignore `.vtcode` workspace metadata Keep local VT Code state out of version control. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index e6c99d614..cf9b0a511 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ target/ vtcode.toml .memdb/ .grepai/ +.vtcode/ build.ninja *:Zone.Identifier /graph.dot From 805a825c78840e4595407b170a02da81ac1d194c Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 10 Aug 2026 14:07:59 +0200 Subject: [PATCH 02/69] Plan serial dependency ordering with dyndep (#552) Record the staged dyndep design, generated sidecar contract, and atomic materialization boundary for ordered target and action dependencies. Define regression evidence for declaration order, shared work, failure short-circuiting, default parallelism, and serialization scope. Document the independent-reachability limit that requires approval before implementation. --- ...ndency-ordering-for-actions-and-targets.md | 829 ++++++++++++++++++ 1 file changed, 829 insertions(+) create mode 100644 docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md diff --git a/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md b/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md new file mode 100644 index 000000000..ca81ab5bf --- /dev/null +++ b/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md @@ -0,0 +1,829 @@ +# Issue 552: Support serial dependency ordering with Ninja dyndep + +This ExecPlan is a living document. Keep `Progress`, `Surprises & discoveries`, +`Decision log`, and `Outcomes & retrospective` current as implementation +proceeds. + +Status: **Draft — awaiting approval before implementation.** + +Issue: [#552](https://github.com/leynos/netsuke/issues/552) + +## Purpose and big picture + +Netsuke currently treats every dependency list as an unordered graph. Users who +want an aggregate action such as `all` to run formatting, linting, tests, and +spelling in declaration order must encode orchestration outside the manifest. +The desired syntax is a closed `dependency_order` field on actions and targets: + +```yaml +actions: + all: + dependency_order: serial + deps: + - check-fmt + - lint + - test + - spelling +``` + +After this change, omitting the field continues to mean `parallel`. A serial +list starts each dependency only after the preceding dependency has completed +successfully, stops before later entries after a failure, and continues to use +one Ninja scheduler so shared work is built at most once per invocation. +Unrelated graph branches remain eligible to run concurrently. + +The observable implementation uses Ninja's dynamic dependency feature, +`dyndep`, introduced in Ninja 1.10. Netsuke emits a staged chain of phony gate +edges. Each stage's dyndep file reveals exactly one real dependency, while the +next dyndep file is not available to Ninja until the preceding gate succeeds. +This differs materially from an order-only chain around already-visible +dependencies: Ninja cannot schedule a dependency before its dyndep statement +has revealed it. + +The implementation is complete when schema, IR, Ninja generation, dyndep-file +materialization, documentation, and regression tests satisfy every acceptance +criterion in issue #552 and all repository gates pass. + +## Constraints + +- Use the declarative field `dependency_order`, with a closed enum containing + `parallel` and `serial`. Do not add a bare Boolean `serial` flag. +- Default to `parallel` so existing manifests and generated Ninja remain + unchanged. +- Apply the field only to a target or action's `deps` list. It must not reorder + `sources`, `order_only_deps`, the dependencies of another node, or the global + worker pool. +- Preserve the user's `deps` declaration order through parsing, IR lowering, + and Ninja generation. +- Keep one top-level Ninja invocation. Do not implement this feature through + nested `netsuke build` or nested `ninja` commands. +- Use Ninja dyndep syntax version 1 and require Ninja 1.10 or newer only when a + generated build uses serial dependency ordering. +- Preserve shared-dependency memoization within the encompassing Ninja build. +- Avoid Ninja pools: depth-one pools provide mutual exclusion, not a + declaration-order guarantee, and would broaden the serialization scope. +- Treat generated dyndep files as part of the generated Ninja artefact. Never + print a main Ninja file that silently relies on sidecars Netsuke has not + materialized. +- Keep generated paths deterministic, content-addressed where appropriate, + relative to the effective Ninja working directory, and isolated beneath the + existing `.netsuke` state namespace. +- Use `cap_std`, `cap_std::fs_utf8`, and `camino` for production file access; + do not introduce `std::fs` or `std::path` production code. +- Materialize sidecars atomically and idempotently so concurrent Netsuke + processes cannot observe partial dyndep content. +- Do not add a new external crate unless implementation proves that the + workspace cannot provide the required digest or atomic-write primitive. +- No production Rust source file may exceed 400 lines. In particular, + `src/ninja_gen.rs` and `src/runner/process/mod.rs` are already near the + limit, so new responsibilities belong in focused submodules. +- Every new module must begin with a `//!` module-level comment, and every new + public API must have Rustdoc with a useful example. +- Follow Red–Green–Refactor. Capture the expected failing focused test before + making it pass, but never commit a state whose required gates fail. +- Update user, design, developer, repository-layout, roadmap, and architectural + decision documentation as described below. +- Run repository gates sequentially, using the shared Cargo cache. Do not run + formatting, linting, or test gates in parallel. +- Do not implement this draft until the user explicitly approves it. + +## Tolerances + +- **Scope:** if correct implementation requires a Netsuke-owned scheduler, + global graph serialization, or a manifest redesign beyond `dependency_order`, + stop and obtain approval. Those are architectural scope changes, not + implementation details. +- **Dependencies:** the target is zero new crates. If an additional crate is + unavoidable, stop and present the crate, version, maintenance status, and why + existing dependencies or standard library facilities are insufficient. +- **Generated-output compatibility:** parallel manifests should retain + byte-for-byte generated Ninja output. If unavoidable output churn appears, + isolate it, explain it, and obtain approval before refreshing broad snapshots. +- **Public API compatibility:** existing callers of `ninja_gen::generate` and + `generate_into` must continue to work for ordinary graphs. A graph containing + serial ordering may require the new bundle API; the string-only APIs must + return a specific error instead of returning an incomplete build file. +- **Performance:** serial lowering may add one gate and one small dyndep file + per dependency. It must remain linear in the number of serial dependencies + and must not traverse unrelated subgraphs repeatedly. +- **State:** `.netsuke/dyndep` is a reusable generated cache. `netsuke clean` + need not delete it, but stale content must be harmless because filenames are + content-addressed. +- **Platform support:** sidecar generation must be shell-independent and use + Rust filesystem APIs so Windows, macOS, and Linux release builds share the + same behaviour. +- **Compatibility boundary:** a later serial dependency that is also directly + requested or independently reachable from another requested top-level branch + may become visible through that other branch and execute early. If acceptance + requires suppressing that independent reachability, stop: static Ninja cannot + both keep ordering local and globally delay the shared node. +- **Time:** there is no deadline tolerance. Prefer a correct, reviewable design + and complete evidence over a rushed implementation. + +## Risks + +- **Later dependencies can leak through another requested branch.** Dyndep + delays only the graph path it controls. Ninja unifies nodes globally, so an + independent path to a later dependency can expose it before the serial gate. + Mitigation: document the boundary, test that a genuinely unrelated branch + remains concurrent, and stop for a scheduler-level redesign if stronger + semantics are required. +- **Incomplete generated artefacts.** A main Ninja file that references absent + dyndep sidecars fails during Ninja graph loading. Mitigation: introduce a + bundle type, route every CLI execution and generation path through it, and + make string-only generation reject serial graphs. +- **Races while writing sidecars.** Two builds may generate the same content at + once. Mitigation: write a unique temporary file in `.netsuke/dyndep`, flush + it, atomically rename it, and treat an already-present matching digest as + success. +- **Synthetic output collisions.** User targets could name a path chosen for a + gate or dyndep file. Mitigation: reserve `.netsuke/serial` and + `.netsuke/dyndep` for this feature, reject exact or prefix collisions with a + localized error, and document the reservation. +- **Freshness propagation can be lost.** Depending only on the final gate may + not make every real dependency contribute to the aggregate target's dirty + state. Mitigation: list every gate as an implicit dependency of the annotated + edge and add repeat-build tests that mutate each dependency in turn. +- **Escaping errors can corrupt dyndep syntax.** Ninja paths have escaping + rules distinct from YAML and shell quoting. Mitigation: reuse the generator's + existing path-rendering machinery, test spaces and Ninja metacharacters, and + avoid manually concatenating unescaped paths. +- **Action outputs are synthetic.** Actions and ordinary targets share the AST + `Target` shape but lower differently. Mitigation: exercise both forms at AST, + IR, snapshot, behavioural, and real-Ninja levels. +- **Ninja version failures may be obscure.** Older Ninja releases do not + support dyndep. Mitigation: emit `ninja_required_version = 1.10` when the + feature is present and document the resulting minimum version. +- **Source-file size pressure.** Adding logic directly to near-limit modules + would violate repository policy. Mitigation: establish focused dyndep + generation and materialization modules before adding the implementation. + +## Progress + +- [x] (2026-08-10 11:59Z) Inspected issue #552, current AST-to-IR-to-Ninja + lowering, runner generation paths, behavioural fixtures, snapshots, and + real-Ninja integration tests. +- [x] (2026-08-10 11:59Z) Falsified the proposed order-only phony gate chain: + real dependencies remain transitively visible and start in parallel. +- [x] (2026-08-10 11:59Z) Falsified recursive per-dependency Ninja execution: + separate child schedulers rebuild a shared dependency more than once. +- [x] (2026-08-10 11:59Z) Validated a minimal dyndep chain with real Ninja: + declaration order, failure short-circuiting, shared work reuse, and unrelated + branch concurrency behaved as required. +- [x] (2026-08-10 11:59Z) Drafted this self-contained implementation plan. +- [ ] Obtain explicit approval for the plan and its compatibility boundary. +- [ ] Add failing schema, IR, generator, behavioural, and runtime regressions. +- [ ] Implement AST and IR representation. +- [ ] Implement deterministic Ninja bundle and dyndep lowering. +- [ ] Implement atomic dyndep sidecar materialization in every CLI path. +- [ ] Complete user, design, developer, layout, roadmap, and ADR documentation. +- [ ] Run focused verification and all repository gates. +- [ ] Commit each green logical change and record final evidence here. + +## Surprises and discoveries + +- An order-only dependency on a phony gate orders only the gate itself. Ninja + eagerly schedules all already-visible transitive inputs, so the real recipes + behind later gates still start concurrently. This invalidates the original + phony-chain proposal even though the gate commands appear ordered. +- Giving every stage a real command that recursively invokes Ninja provides + ordering and failure propagation, but it breaks the shared-dependency + requirement because each child Ninja process owns a separate build memo. +- Dyndep changes the decisive property: the next real dependency is absent + from Ninja's graph until the preceding gate completes and makes the next + dyndep file available. +- Static, pre-materialized dyndep files can still be revealed in stages. A + phony edge may name each existing sidecar as its output and depend on the + previous gate. No generator recipe or nested process is required. +- Ninja's `rspfile_content` binding cannot conveniently encode the required + multiline dyndep document. A literal `\n` remains literal and produces an + invalid file. Netsuke therefore needs to materialize sidecars itself. +- `src/ninja_gen.rs` is currently 400 lines and + `src/runner/process/mod.rs` is close to that limit. The implementation must + be modular rather than appended to those files. +- Netsuke already owns the `.netsuke` workspace-state namespace through its + fetch cache, so `.netsuke/dyndep` does not introduce a second state root. + +## Decision log + +- **Decision:** use staged Ninja dyndep files rather than an order-only gate + chain, a pool, or recursive builds. **Rationale:** it is the only evaluated + design that keeps a single scheduler, prevents later dependencies from + becoming schedulable through the annotated path, propagates failure, and + leaves unrelated work unconstrained. **Date:** 2026-08-10. +- **Decision:** add `DependencyOrder::{Parallel, Serial}` to the AST and carry + it explicitly on each IR `BuildEdge`. **Rationale:** a closed enum rejects + misspellings, preserves future extension space, and avoids inferring + scheduling policy from graph shape. **Date:** 2026-08-10. +- **Decision:** apply `dependency_order` only to `Target::deps`. + **Rationale:** actions already use the target shape, while sources and + order-only dependencies have distinct freshness semantics not covered by the + issue. **Date:** 2026-08-10. +- **Decision:** preserve ordinary dependency nodes in IR and perform + Ninja-specific staged lowering in the Ninja generator. **Rationale:** dyndep + gates are a backend mechanism, not a user graph concept; keeping them out of + IR preserves cycle diagnostics and other backends' view of the manifest. + **Date:** 2026-08-10. +- **Decision:** introduce a generated bundle containing the main Ninja text and + zero or more dyndep sidecars. **Rationale:** string-only generation cannot + represent the complete executable artefact. A bundle makes omission of + required sidecars difficult. **Date:** 2026-08-10. +- **Decision:** store immutable, content-addressed sidecars beneath + `.netsuke/dyndep`, and gates beneath `.netsuke/serial`. **Rationale:** + deterministic names make generation reproducible, reuse safe, and stale cache + entries harmless. **Date:** 2026-08-10. +- **Decision:** generate no dyndep chain for zero- or one-element serial lists. + **Rationale:** no relative ordering exists to enforce, so ordinary lowering + is equivalent and avoids unnecessary generated state. **Date:** 2026-08-10. +- **Decision:** list all generated gates, in order, as implicit dependencies of + the annotated edge. **Rationale:** every real dependency must continue to + participate in dirty checking; relying only on the final gate obscures that + invariant. **Date:** 2026-08-10. +- **Decision:** document independent reachability as a semantic boundary rather + than globally constraining shared nodes. **Rationale:** global delay would + serialize unrelated branches and violate the scoped-behaviour acceptance + criterion. Stronger semantics require a Netsuke scheduler and explicit + approval. **Date:** 2026-08-10. +- **Decision:** record the architecture in a new ADR before calling the feature + complete. **Rationale:** the Ninja version floor, generated sidecars, state + namespace, and public generator contract are durable choices that are costly + to reverse. **Date:** 2026-08-10. + +## Outcomes and retrospective + +No implementation has started. On completion, replace this paragraph with the +observable behaviour delivered, gate results, commit identifiers, deviations +from the plan, and lessons for future scheduling features. + +## Context and orientation + +The relevant pipeline is deliberately small: + +```plaintext +Netsukefile YAML + -> src/ast.rs Target + -> src/ir/from_manifest.rs process_targets + -> src/ir/graph.rs BuildEdge + -> src/ninja_gen.rs generated Ninja artefact + -> src/runner/* materialization and Ninja invocation +``` + +In this plan, a *real dependency* is the action or target named by a manifest +`deps` entry. A *gate* is a synthetic phony Ninja output representing one +position in a serial list. A *dyndep sidecar* is a small Ninja-syntax document +that adds one real dependency to one gate after Ninja has loaded the main build +file. A *bundle* is the main build-file text plus every sidecar required to +execute it. + +The syntax and graph-loading constraints used below follow the official +[Ninja dyndep reference](https://ninja-build.org/manual.html#ref_dyndep). In +particular, the main edge names its dyndep file as an input and each sidecar +contains the version header plus a one-to-one update for that edge. + +`src/ast.rs` defines `Target`, which is shared by ordinary targets and actions. +Its `deps` vector is already ordered by YAML declaration. Add a serde-backed +enum here rather than representing ordering as a string or Boolean. + +`src/ir/from_manifest.rs::process_targets` currently transfers `Target::deps` to +`BuildEdge::implicit_deps`. Keep the vector unchanged and copy the new enum to +the edge. Existing cycle detection continues to inspect the real dependency +graph rather than generated gates. + +`src/ir/graph.rs` defines `BuildEdge`. The field belongs here because +generation must know whether the edge's implicit dependencies are ordered. Many +tests and Rustdoc examples construct `BuildEdge` directly; update every literal +mechanically and default it to parallel. + +`src/ninja_gen.rs` currently renders a graph to one string. Extract dyndep +identifier, sidecar, and staged-edge construction into +`src/ninja_gen/dyndep.rs`. Keep the top-level module responsible for ordinary +rendering and selecting the staged representation. + +`src/runner/mod.rs` and `src/runner/process/mod.rs` connect generation to +`build`, `clean`, and `generate`. Add sidecar materialization in a new focused +module such as `src/runner/process/dyndep_files.rs`; do not let a caller invoke +Ninja with a serial main file until its bundle is materialized. + +The principal existing tests are: + +- `tests/ast_tests/parsing.rs` and `tests/ast_tests/actions.rs` for manifest + syntax; +- `tests/ir_from_manifest_tests.rs` for dependency lowering; +- `tests/ninja_snapshot_tests.rs` for stable generated Ninja; +- `tests/ninja_gen_integration_tests.rs` for real Ninja execution; +- `tests/features/ninja.feature` and `tests/bdd/steps/ninja.rs` for externally + described generation behaviour; and +- `test_support/src/ninja_gen.rs` for shared generator fixtures. + +The current real-Ninja no-op test intentionally runs Ninja once to populate +`.ninja_log` before asserting that a second invocation is a no-op. Preserve +that pattern in serial freshness tests so the result reflects Ninja's normal +incremental state rather than a cold build. + +## Proposed generated form + +For a target `all` whose serial dependencies are `check-fmt`, `lint`, and +`test`, generate deterministic paths represented schematically below. The +actual identifiers use stable digests and escaped Ninja paths. + +```ninja +ninja_required_version = 1.10 + +build .netsuke/dyndep/.dd: phony +build .netsuke/serial//000: phony || .netsuke/dyndep/.dd + dyndep = .netsuke/dyndep/.dd + +build .netsuke/dyndep/.dd: phony .netsuke/serial//000 +build .netsuke/serial//001: phony || .netsuke/dyndep/.dd + dyndep = .netsuke/dyndep/.dd + +build .netsuke/dyndep/.dd: phony .netsuke/serial//001 +build .netsuke/serial//002: phony || .netsuke/dyndep/.dd + dyndep = .netsuke/dyndep/.dd + +build all: | .netsuke/serial//000 $ + .netsuke/serial//001 .netsuke/serial//002 +``` + +The first sidecar contains: + +```ninja +ninja_dyndep_version = 1 +build .netsuke/serial//000: dyndep | check-fmt +``` + +The later sidecars have the same shape for `lint` and `test`. Ninja can load +the first sidecar immediately and therefore schedule `check-fmt`. The second +sidecar's phony-producing edge depends on the first gate, so `lint` remains +unknown through this path until `check-fmt` succeeds. If `check-fmt` fails, the +first gate never completes, the second sidecar never becomes available, and +later dependencies are not scheduled through the serial list. + +Each gate is a phony alias of exactly one real dependency. Repeated or diamond +dependencies still name the same real Ninja node, so the single Ninja scheduler +executes that node at most once. + +## Plan of work + +### Stage 1: Establish red behavioural contracts + +Add parser tests for targets and actions covering omitted ordering, explicit +`parallel`, explicit `serial`, and rejection of an unknown value such as +`sequential`. Add IR tests showing that dependency order and the original +`implicit_deps` sequence survive lowering. + +Add generator tests that describe a complete bundle rather than only the main +string. The first red assertion should require: + +- `ninja_required_version = 1.10` only for a multi-dependency serial edge; +- one deterministic gate and sidecar per dependency; +- each later sidecar-producing edge to depend explicitly on the previous gate; +- one dyndep statement per gate with the matching real dependency; +- every gate to appear on the annotated edge in declaration order; and +- ordinary parallel snapshots to remain unchanged. + +Add a Gherkin scenario and fixture at `tests/data/dependency_order_serial.yml`. +The scenario should compile a target and an action to IR, generate a bundle, +and inspect the ordered dependency names revealed by its sidecars. The feature +text should describe user behaviour, not implementation internals beyond the +fact that valid staged dyndep output is generated. + +Capture the failing focused commands and their failure messages in the +`Progress` section. Then implement enough of stages 2–4 to make the tests green +before committing. + +### Stage 2: Add the AST and IR contract + +In `src/ast.rs`, add the closed enum and target field. The intended public +shape is: + +```rust +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum DependencyOrder { + #[default] + Parallel, + Serial, +} + +pub struct Target { + // Existing fields remain in their current order. + #[serde(default)] + pub dependency_order: DependencyOrder, +} +``` + +Adjust derives to match the surrounding AST types and add Rustdoc examples +showing the default and serial forms. Do not add this field to `Rule`. + +In `src/ir/graph.rs`, add `dependency_order: DependencyOrder` to `BuildEdge`. In +`src/ir/from_manifest.rs`, copy the parsed value while leaving `implicit_deps` +in source order. Update every direct `BuildEdge` construction, fixture, and +doctest to specify the default explicitly or use a shared test constructor +where one already exists. Do not introduce a new general-purpose builder solely +to conceal updates. + +Run the parser and IR tests. Also run existing cycle tests to prove the new +field does not change graph validation. + +### Stage 3: Generate a complete Ninja bundle + +Before extracting any helper, repeat the repository sweep for equivalent +bundle, digest, path-escape, and sidecar abstractions. Record the ownership and +reuse boundary in `docs/developers-guide.md`: the new types belong to Ninja +generation and may be consumed by runner/output adapters, but must not become +generic manifest or filesystem abstractions. + +Add types along these lines, refining names to fit existing conventions: + +```rust +pub struct GeneratedNinja { + build_file: String, + dyndep_files: Vec, +} + +pub struct GeneratedDyndep { + relative_path: Utf8PathBuf, + content: String, +} + +pub fn generate_bundle(graph: &BuildGraph) -> Result; +``` + +Expose read-only accessors or consuming methods needed by the CLI. Keep fields +private if callers do not need to construct invalid bundles. Document that all +paths are relative to the effective Ninja working directory. + +Keep `generate` and `generate_into` source-compatible for graphs without serial +ordering. If called with a multi-dependency serial edge, return a localized +`NinjaGenError::DyndepFilesRequired` directing the caller to `generate_bundle`; +never return main text that cannot run alone. Perform this check before writing +any bytes so `generate_into` cannot leave partial output in its caller's writer. + +Implement the staged lowering in `src/ninja_gen/dyndep.rs`: + +1. Iterate serial `implicit_deps` in their stored order. +2. Derive a stable parent identity from the annotated edge's canonical output + identity and an explicit schema/version tag. +3. Derive a gate path from that identity and a zero-padded position. +4. Render the sidecar content using the generator's existing Ninja path + escaping. +5. Derive the `.dd` filename from a cryptographic digest of the complete + sidecar bytes and a format-version tag. +6. Emit a phony sidecar edge. Starting with the second stage, make it explicitly + depend on the prior gate. +7. Emit a phony gate edge with the sidecar as an order-only input and its + `dyndep` binding. +8. Replace the annotated edge's direct implicit dependencies with all gate + outputs in the same order. + +Deduplicate identical sidecar content in the bundle by relative path, but do +not collapse gate positions. This preserves the declaration sequence while +allowing the real Ninja node to remain shared. + +Return a localized collision error when a user output occupies the reserved +`.netsuke/serial` or `.netsuke/dyndep` namespace. Add unit coverage for the +error and documentation for the reservation. + +Do not emit the Ninja version binding or any generated files for parallel, +empty serial, or one-element serial lists. Add tests for all three cases. + +### Stage 4: Materialize dyndep files atomically + +Extend the internal generated-content wrapper used by +`src/runner/process/mod.rs` so it carries `GeneratedNinja`, while preserving +the current main-text access required by stdout and JSON output. + +Create `src/runner/process/dyndep_files.rs`. Its single responsibility is to +materialize the bundle's sidecars under the effective Ninja working directory. +Its algorithm should be: + +1. Open the effective working directory through the existing capability-based + filesystem seam. Honour CLI `-C`; otherwise use the current directory. +2. Create `.netsuke/dyndep` if it is absent. +3. If a final content-addressed file exists, read it and verify its content. + Matching content is success; mismatched content is a corruption error. +4. Otherwise create a unique same-directory temporary file with `create_new`, + write all bytes, flush them, and rename it atomically to the final path. +5. If another process wins the rename race, verify the winning file and treat + matching content as success. +6. Clean up only the temporary file owned by this attempt. Never truncate or + replace an existing final sidecar in place. + +Use a narrow injected filesystem seam only if required for deterministic unit +tests; first reuse the runner's existing capability abstractions. Test initial +creation, idempotent reuse, corrupt-content rejection, nested-directory +creation, and the competing-writer outcome without mutating process-wide +environment variables. + +Route all executable and export paths through bundle materialization: + +- `netsuke build` materializes before writing or invoking the main Ninja file; +- `netsuke clean` does the same because loading a serial build file requires + its dyndep inputs even when cleaning; +- `netsuke generate --output ` materializes relative to the effective + Ninja working directory and writes the main file; +- `netsuke generate --stdout` and JSON output also materialize sidecars, then + return only the main file text in their existing output field. + +Document the side effect of stdout/JSON generation. If analysis shows that an +output path outside the working directory makes relative sidecars ambiguous, +retain the working-directory rule rather than inferring a new base from the +output filename. + +### Stage 5: Prove runtime semantics with real Ninja + +Create a focused integration-test module, splitting existing files if needed to +remain below 400 lines. Use actual Ninja processes and filesystem markers, not +assertions over textual edge order alone. + +Add these cases: + +- **Declaration order:** dependency one writes marker `one`; dependency two + first requires `one`, then writes `two`; dependency three requires `two`. The + aggregate succeeds only if recipes start in order. +- **Shared dependency:** the first and second serial entries both depend on a + real `common` node that appends one log entry. Assert exactly one entry for + `common` in one Ninja invocation. +- **Literal repeated entry:** use the same dependency twice in one serial list + and prove its recipe runs once while both gate stages complete. +- **Failure short-circuit:** make the first dependency fail deliberately and + assert no marker or log line exists for later dependencies. +- **Default parallel behaviour:** with `dependency_order` omitted, have two + dependencies create start markers and wait with a bounded timeout for the + peer marker. They succeed only if Ninja may run them concurrently. +- **Scoped serialization:** make the first serial dependency and an unrelated + requested branch meet at a bounded barrier. Assert both start concurrently, + while the second serial dependency begins only after the first finishes. +- **Incremental freshness:** perform a cold build, then a no-op build. Mutate + each real serial dependency in turn and assert the aggregate rebuilds. Finish + with another true no-op invocation after `.ninja_log` has been populated. +- **Path escaping:** use dependency and target paths containing spaces and a + Ninja metacharacter, and prove both main and dyndep files load correctly. + +Avoid timing-only assertions. Markers and bounded handshakes should establish +happens-before and concurrency. A timeout is only a deadlock guard and should +be generous enough for loaded CI workers. + +The scoped test intentionally uses an unrelated branch. Do not add a test that +claims a later dependency independently exposed by another branch will remain +hidden; that is outside the stated compatibility boundary. + +### Stage 6: Document the feature and its architecture + +Create `docs/adr-010-use-ninja-dyndep-for-serial-dependency-ordering.md` using +the repository ADR format. Verify the next ADR number immediately before +creation. The ADR must include a Y-statement and record: + +- the rejected order-only gate, pool, and recursive-build alternatives; +- the Ninja 1.10 version floor for serial builds; +- the generated bundle and `.netsuke` namespace; +- the single-scheduler shared-dependency property; +- the independent-reachability boundary; and +- consequences for generated-output consumers and cache cleanup. + +Mark the ADR `Proposed` while implementation is in progress and `Accepted` only +after behaviour and gates pass. Add it to `docs/contents.md`. + +Update: + +- `docs/users-guide.md` with action and target syntax, defaulting, ordered + execution, failure, shared work, scope, Ninja version, generated state, and + the independent-reachability boundary; +- `docs/netsuke-design.md` with the AST/IR policy, generated bundle, staged + dyndep graph, and why gates remain backend-only; +- `docs/developers-guide.md` with bundle ownership, materialization invariants, + reserved paths, and focused-test guidance; +- `docs/repository-layout.md` with the new generator/runner submodules and + `.netsuke/dyndep` state; +- `docs/roadmap.md` with issue #552 status, marking it complete only after all + evidence is present; and +- relevant Rustdoc on `DependencyOrder`, bundle APIs, errors, and the + materializer. + +Use en-GB-oxendict prose, 80-column paragraphs, attributed code fences, and the +documentation style guide. Run `make fmt` after documentation edits and inspect +the diff so unrelated mechanical reflow is not included. + +### Stage 7: Refactor, validate, and commit + +Once the feature is green, review the changed code and its neighbours for +duplication, long functions, excessive parameters, complex conditionals, and +feature envy. Any non-essential refactor belongs in a separate subsequent +commit and must pass the same gates. Do not broaden the feature commit merely +to tidy unrelated code. + +Use small green commits. A suitable sequence is: + +1. add the AST/IR contract and its tests; +2. add Ninja bundle generation, materialization, and semantic regressions; +3. document the syntax and architectural decision; and +4. apply a separate focused refactor only if post-commit review justifies one. + +Before every commit, run the relevant focused tests and gates. Before declaring +the work complete, run all project gates sequentially through the repository's +gate-running workflow: + +```bash +make check-fmt +make typecheck +make lint +make test +make markdownlint +make nixie +``` + +Capture each command with `tee` to a branch-specific file under `/tmp`, inspect +the complete log on failure, and record the final result and log path in +`Progress`. Do not substitute a narrower command for a named project gate. + +## Concrete implementation steps + +All commands run from the repository root: + +```plaintext +/home/leynos/.lody/repos/github---leynos---netsuke/worktrees/ +6c498022-7fb6-49a9-94a9-56723bb7d1e1 +``` + +First confirm branch and cleanliness: + +```bash +git branch --show-current +git status --short +``` + +Locate all construction and generation seams before editing: + +```bash +rg -n 'Target \{|BuildEdge \{|generate_into|generate\(' src tests test_support +rg -n 'NinjaContent|handle_build|handle_clean|handle_generate' src tests +rg -n '\.netsuke|cap_std|fs_utf8|rename' src tests +``` + +Run the existing focused baseline tests before adding red cases: + +```bash +cargo nextest run --test ast_tests --test ir_from_manifest_tests +cargo nextest run --test ninja_snapshot_tests --test ninja_gen_integration_tests +cargo nextest run --test bdd -- ninja +``` + +Use the exact test-binary and filter names discovered by `cargo nextest list` +if the last BDD filter is not accepted. Record any pre-existing failure before +editing and do not attribute it to this work. + +After adding each red test group, run only that group, record the expected +failure, implement the smallest corresponding production slice, and rerun until +green. Example commands, to be adjusted to the final test names, are: + +```bash +cargo nextest run --test ast_tests dependency_order +cargo nextest run --test ir_from_manifest_tests dependency_order +cargo nextest run --test ninja_snapshot_tests serial_dependency +cargo nextest run --test ninja_gen_integration_tests serial_dependency +cargo nextest run --test bdd serial_dependencies +``` + +After changes to Rust or Markdown, format once and inspect the resulting diff: + +```bash +make fmt +git status --short +git diff --check +git diff --stat +``` + +Then run the complete sequential gates listed in stage 7. Use the +commit-message skill to prepare each commit message in imperative mood with a +wrapped body. Do not push or open a pull request unless separately requested. + +## Validation and acceptance + +Acceptance is evidence-based. The following must all be true: + +- A target and an action both accept `dependency_order: serial`. +- `parallel` is accepted explicitly, omission defaults to it, and any unknown + enum value produces a localized manifest error. +- Serial `deps` retain declaration order from YAML through IR and every staged + dyndep sidecar. +- A real-Ninja test proves ordered start, not merely ordered gate completion. +- A real-Ninja test proves later dependencies do not start after an earlier + failure. +- Shared and repeated real dependencies execute once in one top-level Ninja + invocation. +- A real-Ninja barrier test proves omitted ordering remains parallel. +- A real-Ninja barrier test proves an unrelated graph branch remains parallel + with the active serial stage. +- Sources and order-only dependencies remain governed by their existing + semantics. +- Rebuild and no-op tests prove every serial dependency still participates in + aggregate freshness. +- Serial generation produces a complete bundle, uses valid dyndep syntax, and + declares Ninja 1.10 as the required version. +- Parallel generation produces no dyndep files and preserves existing snapshot + output. +- Sidecar writes are deterministic, idempotent, atomic, capability-oriented, + and covered for corruption and race outcomes. +- CLI build, clean, file output, stdout output, and JSON output never expose or + execute an unmaterialized serial bundle. +- User and internal documentation state the exact guarantees and the + independent-reachability boundary. +- No source file exceeds 400 lines, no lint is suppressed for convenience, and + every repository gate passes. + +The behavioural feature should contain a scenario equivalent to: + +```gherkin +Scenario: Serial dependencies preserve their declaration order + Given a manifest with a serial target depending on check-fmt, lint, and test + When the manifest is compiled and its Ninja bundle is generated + Then the target dependency order is serial + And the dyndep stages reveal check-fmt, lint, and test in that order +``` + +The runtime tests, rather than this textual scenario alone, are authoritative +for concurrency, failure, and shared-execution semantics. + +## Idempotence and recovery + +Generation is deterministic: identical graph input produces identical main +text, sidecar paths, and sidecar bytes. Repeating materialization reuses an +existing sidecar only after verifying its content. Content-addressed filenames +mean abandoned older files do not affect the new graph. + +If Netsuke is interrupted before rename, only its uniquely named temporary file +may remain. A later run may ignore or remove that temporary file and safely +retry. Never use a broad recursive deletion to recover. If a final digest path +contains mismatched bytes, report corruption with the exact relative path and +require the user to remove that single cache file before retrying. + +`netsuke clean` cleans build outputs through Ninja but may leave immutable +dyndep cache entries. Document manual recovery as removal of the narrow +`.netsuke/dyndep` directory only; never suggest deleting the entire workspace or +`.netsuke` root. + +If an implementation experiment shows that the dyndep sequence does not meet +one of the validated invariants, preserve the failing fixture, update +`Surprises & discoveries`, revert only the uncommitted experiment, and return +to the last green commit. Do not compensate with a pool or nested invocation. + +## Artefacts and notes + +During implementation, retain concise evidence in this document: + +- the first failing assertion for each red test group; +- one representative generated main-file fragment and sidecar; +- the observed marker/log order from the real-Ninja ordering test; +- proof that the shared dependency log contains one entry; +- proof that failure leaves later markers absent; +- proof that the unrelated branch crosses the concurrency barrier; and +- final gate commands, log paths, and commit identifiers. + +Do not paste complete build logs or broad snapshots into the plan. Store logs +under `/tmp` and summarize the decisive lines here. + +## Interfaces and dependencies + +The intended interfaces at completion are: + +```rust +pub enum DependencyOrder { + Parallel, + Serial, +} + +pub struct BuildEdge { + // Existing fields. + pub dependency_order: DependencyOrder, +} + +pub struct GeneratedNinja { /* private fields */ } + +pub struct GeneratedDyndep { /* private fields */ } + +pub fn generate_bundle(graph: &BuildGraph) -> Result; +``` + +`GeneratedNinja` must provide the main build text and a read-only or consuming +view of its sidecars. `GeneratedDyndep` must expose only a relative UTF-8 path +and immutable content. The runner materializer consumes those values but does +not decide graph structure or naming. + +`NinjaGenError` gains localized variants for requesting string-only output from +a serial graph and for reserved-output collisions. The materializer gains a +typed or contextual error for directory creation, temporary writes, rename +races, and digest-path corruption. Preserve domain errors within libraries and +convert to `eyre` only at the application boundary, following existing runner +conventions. + +No external dependency is planned. Reuse the workspace's current hashing, UTF-8 +path, localization, error, and capability-filesystem facilities after +confirming their exact APIs in `Cargo.toml` and existing call sites. + +## Revision note + +2026-08-10: Initial draft. It replaces the disproven order-only phony-gate +proposal with a staged dyndep bundle, adds atomic sidecar materialization, and +records the independent-reachability limit that must be approved with the +implementation approach. From 5a859ab8aafdcfda7b6c7621ff5a57dddb0ec423 Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 10 Aug 2026 20:56:05 +0200 Subject: [PATCH 03/69] Add serial dependency_order contract to AST and IR (#552) Introduce the closed `DependencyOrder::{Parallel, Serial}` enum on `Target` and carry it on every `BuildEdge`, defaulting to parallel so existing manifests and generated Ninja remain unchanged. Copy the value during manifest-to-IR lowering while preserving the declaration order of `implicit_deps`, and re-export it through the `ir` module. Cover the new surface with regressions: omission defaults to parallel, explicit parallel and serial parse on targets and actions, unknown values such as `sequential` are rejected, and serial targets and actions retain their order and policy through lowering. Update every direct `BuildEdge` literal, doctest, and fixture to compile against the widened struct. Update the execplan progress and discoveries with the validated Ninja dyndep loading and path-escaping semantics. --- ...ndency-ordering-for-actions-and-targets.md | 46 +++++++- src/ast/mod.rs | 28 +++++ src/graph_view/tests_support.rs | 1 + src/ir/cycle_issue322_property_tests.rs | 1 + src/ir/cycle_property_tests.rs | 1 + src/ir/cycle_tests.rs | 1 + src/ir/cycle_verification.rs | 1 + src/ir/from_manifest.rs | 1 + src/ir/graph.rs | 6 +- src/ir/mod.rs | 1 + src/ninja_gen.rs | 1 + src/ninja_gen_property_tests.rs | 1 + tests/ast_tests/parsing.rs | 100 ++++++++++++++++++ tests/ir_from_manifest_tests.rs | 72 ++++++++++++- tests/ir_tests.rs | 3 + tests/ninja_gen_integration_tests.rs | 6 ++ tests/ninja_gen_unit_tests.rs | 8 ++ 17 files changed, 272 insertions(+), 6 deletions(-) diff --git a/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md b/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md index ca81ab5bf..242ce89bd 100644 --- a/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md +++ b/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md @@ -4,7 +4,7 @@ This ExecPlan is a living document. Keep `Progress`, `Surprises & discoveries`, `Decision log`, and `Outcomes & retrospective` current as implementation proceeds. -Status: **Draft — awaiting approval before implementation.** +Status: **In development — plan approved by the user on 2026-08-10; implementation in progress.** Issue: [#552](https://github.com/leynos/netsuke/issues/552) @@ -171,9 +171,18 @@ criterion in issue #552 and all repository gates pass. declaration order, failure short-circuiting, shared work reuse, and unrelated branch concurrency behaved as required. - [x] (2026-08-10 11:59Z) Drafted this self-contained implementation plan. -- [ ] Obtain explicit approval for the plan and its compatibility boundary. -- [ ] Add failing schema, IR, generator, behavioural, and runtime regressions. -- [ ] Implement AST and IR representation. +- [x] (2026-08-10) User approved the plan and its compatibility boundary via the + implementation request; the developer also received the repository gate list. +- [x] (2026-08-10) Added schema and IR regressions: omission defaults to + `parallel`, explicit `parallel`/`serial` parse on targets and actions, an + unknown value such as `sequential` is rejected, and declaration order and the + `DependencyOrder` survive lowering for both targets and actions. +- [x] (2026-08-10) Implemented the AST and IR representation: + `DependencyOrder::{Parallel, Serial}` on `Target` (serde lowercase, default + parallel) and on `BuildEdge`, copied during `from_manifest` lowering, and + re-exported through `ir`. Updated every direct `BuildEdge` literal, doctest, + and fixture to compile. `cargo check --all-targets` and 739 lib + touched + integration tests pass. - [ ] Implement deterministic Ninja bundle and dyndep lowering. - [ ] Implement atomic dyndep sidecar materialization in every CLI path. - [ ] Complete user, design, developer, layout, roadmap, and ADR documentation. @@ -182,6 +191,35 @@ criterion in issue #552 and all repository gates pass. ## Surprises and discoveries +- (2026-08-10) Re-validated the staged dyndep chain with real Ninja 1.11.1: + declaration order holds when all sidecars are pre-materialized; a later + sidecar is revealed only after the preceding gate; failure of an early real + dependency stops later stages from being scheduled; and unrelated branches + remain available. The chain requires no generator recipe and no nested Ninja + process. +- (2026-08-10) Ninja path escaping in build/dyndep documents uses `$` as the + escape character. Spaces, `$`, `:`, `|`, and similar metacharacters in target + or dependency paths must be escaped as `$ ` (space is `$ `), `$$` for a + literal dollar, `$:` for colon, `$|` for pipe. Unescaped spaces split a token + into multiple paths. The generator therefore needs a dedicated Ninja + path-escape helper distinct from the existing shell-script escaping. + +- (2026-08-10) Ninja resolves every path named in a build file — including a + `dyndep =` value and every path inside the referenced dyndep document — + relative to Ninja's process working directory, which is the `-C` directory + when one is supplied. The directory containing the main build file does not + affect path resolution. Confirmed with Ninja 1.11.1: with the main build + file in an OS temp directory and `-C` set to the user's project directory, + a sidecar written beneath `project/.netsuke/dyndep/` is located, loaded, and + its revealed dependency built correctly. The runner therefore needs no + architectural change; the plan's existing `.netsuke` navigation already + matches Ninja's model. +- A dyndep document updates the edge by naming the edge's *outputs*, not the + dyndep file itself. The first failed probes named the sidecar path in the + sidecar's `build` statement, which Ninja rejects with + `not mentioned in its dyndep file`. The accepted form is + `build : dyndep | `. + - An order-only dependency on a phony gate orders only the gate itself. Ninja eagerly schedules all already-visible transitive inputs, so the real recipes behind later gates still start concurrently. This invalidates the original diff --git a/src/ast/mod.rs b/src/ast/mod.rs index 9b51e4416..f45e315ad 100644 --- a/src/ast/mod.rs +++ b/src/ast/mod.rs @@ -140,6 +140,30 @@ pub struct Rule { pub description: Option, } +/// Ordering policy applied to a target or action deps list. +/// +/// Omission means [`DependencyOrder::Parallel`], preserving the existing +/// unordered-graph behaviour. A serial list starts each dependency only after +/// the preceding dependency has completed successfully. +/// +/// ```yaml +/// targets: +/// - name: all +/// dependency_order: serial +/// deps: +/// - check-fmt +/// - test +/// ``` +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum DependencyOrder { + /// Dependencies may run in any order, subject to the Ninja scheduler. + #[default] + Parallel, + /// Dependencies run in declaration order, one after another. + Serial, +} + /// Execution style for rules and targets. /// /// Exactly one variant must be provided for a rule or target. The fields are @@ -240,6 +264,10 @@ pub struct Target { #[serde(default)] pub deps: StringOrList, + /// Ordering policy applied to the deps list. + #[serde(default)] + pub dependency_order: DependencyOrder, + /// Dependencies that do not cause a rebuild when changed. #[serde(default)] pub order_only_deps: StringOrList, diff --git a/src/graph_view/tests_support.rs b/src/graph_view/tests_support.rs index 5ea73b098..dd81aa02a 100644 --- a/src/graph_view/tests_support.rs +++ b/src/graph_view/tests_support.rs @@ -61,6 +61,7 @@ pub(super) fn add_edge(graph: &mut BuildGraph, fixture: EdgeFixture<'_>) { action_id: fixture.action_id.into(), inputs: fixture.inputs.iter().map(|s| p(s)).collect(), implicit_deps: fixture.implicit_deps.iter().map(|s| p(s)).collect(), + dependency_order: crate::ast::DependencyOrder::Parallel, explicit_outputs: fixture.explicit_outputs.iter().map(|s| p(s)).collect(), implicit_outputs: fixture.implicit_outputs.iter().map(|s| p(s)).collect(), order_only_deps: fixture.order_only_deps.iter().map(|s| p(s)).collect(), diff --git a/src/ir/cycle_issue322_property_tests.rs b/src/ir/cycle_issue322_property_tests.rs index 6f667804a..56ca64793 100644 --- a/src/ir/cycle_issue322_property_tests.rs +++ b/src/ir/cycle_issue322_property_tests.rs @@ -21,6 +21,7 @@ fn build_edge(output: Utf8PathBuf) -> BuildEdge { action_id: "id".into(), inputs: Vec::new(), implicit_deps: Vec::new(), + dependency_order: crate::ast::DependencyOrder::Parallel, explicit_outputs: vec![output], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), diff --git a/src/ir/cycle_property_tests.rs b/src/ir/cycle_property_tests.rs index 16b31d7cd..f7b4f0b89 100644 --- a/src/ir/cycle_property_tests.rs +++ b/src/ir/cycle_property_tests.rs @@ -48,6 +48,7 @@ impl EdgeBuilder { action_id: "id".into(), inputs: self.inputs, implicit_deps: self.implicit_deps, + dependency_order: crate::ast::DependencyOrder::Parallel, explicit_outputs: vec![self.output], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), diff --git a/src/ir/cycle_tests.rs b/src/ir/cycle_tests.rs index 5a6ef85a2..6ef978e31 100644 --- a/src/ir/cycle_tests.rs +++ b/src/ir/cycle_tests.rs @@ -12,6 +12,7 @@ fn build_edge(inputs: &[&str], implicit_deps: &[&str], output: &str) -> BuildEdg action_id: "id".into(), inputs: inputs.iter().map(|name| path(name)).collect(), implicit_deps: implicit_deps.iter().map(|name| path(name)).collect(), + dependency_order: crate::ast::DependencyOrder::Parallel, explicit_outputs: vec![path(output)], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), diff --git a/src/ir/cycle_verification.rs b/src/ir/cycle_verification.rs index 14e3883d9..2615fe7f0 100644 --- a/src/ir/cycle_verification.rs +++ b/src/ir/cycle_verification.rs @@ -341,6 +341,7 @@ fn edge(output: &str, inputs: Vec, implicit_deps: Vec) action_id: "id".to_owned(), inputs, implicit_deps, + dependency_order: crate::ast::DependencyOrder::Parallel, explicit_outputs: vec![path(output)], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), diff --git a/src/ir/from_manifest.rs b/src/ir/from_manifest.rs index 5cbd3ccc3..926fddbd3 100644 --- a/src/ir/from_manifest.rs +++ b/src/ir/from_manifest.rs @@ -112,6 +112,7 @@ impl BuildGraph { action_id, inputs, implicit_deps, + dependency_order: target.dependency_order, explicit_outputs: outputs, implicit_outputs: Vec::new(), order_only_deps: to_paths(&target.order_only_deps), diff --git a/src/ir/graph.rs b/src/ir/graph.rs index 3286f77b7..2593d7bae 100644 --- a/src/ir/graph.rs +++ b/src/ir/graph.rs @@ -14,7 +14,7 @@ use serde::Serialize; use std::collections::HashMap; use thiserror::Error; -use crate::ast::Recipe; +use crate::ast::{DependencyOrder, Recipe}; #[cfg(kani)] #[path = "graph_kani_map.rs"] @@ -69,6 +69,10 @@ pub struct BuildEdge { pub inputs: Vec, /// Implicit dependencies that trigger a rebuild without entering recipes. pub implicit_deps: Vec, + /// Ordering policy applied to `implicit_deps` when they come from a + /// manifest `deps` list. Parallel is the default; serial dependencies are + /// lowered into staged Ninja dyndep gates by the generator. + pub dependency_order: DependencyOrder, /// Outputs explicitly generated by the command. pub explicit_outputs: Vec, /// Outputs implicitly generated by the command (Ninja `|`). diff --git a/src/ir/mod.rs b/src/ir/mod.rs index 6ad5d43e2..1ad80bc95 100644 --- a/src/ir/mod.rs +++ b/src/ir/mod.rs @@ -29,5 +29,6 @@ mod cycle; mod from_manifest; mod graph; +pub use crate::ast::DependencyOrder; pub(crate) use cmd_interpolate::{INS_TOKEN, OUTS_TOKEN}; pub use graph::{Action, BuildEdge, BuildGraph, IrGenError}; diff --git a/src/ninja_gen.rs b/src/ninja_gen.rs index 2353e6fba..44367938f 100644 --- a/src/ninja_gen.rs +++ b/src/ninja_gen.rs @@ -97,6 +97,7 @@ pub fn generate(graph: &BuildGraph) -> Result { /// graph.targets.insert(Utf8PathBuf::from("out"), BuildEdge { /// action_id: "a".into(), inputs: Vec::new(), /// implicit_deps: Vec::new(), +/// dependency_order: netsuke::ast::DependencyOrder::Parallel, /// explicit_outputs: vec![Utf8PathBuf::from("out")], /// implicit_outputs: Vec::new(), order_only_deps: Vec::new(), /// phony: false, always: false diff --git a/src/ninja_gen_property_tests.rs b/src/ninja_gen_property_tests.rs index facb02e57..45f5910db 100644 --- a/src/ninja_gen_property_tests.rs +++ b/src/ninja_gen_property_tests.rs @@ -40,6 +40,7 @@ fn edge_strategy_with_ranges( action_id, inputs, implicit_deps, + dependency_order: crate::ast::DependencyOrder::Parallel, explicit_outputs, implicit_outputs, order_only_deps, diff --git a/tests/ast_tests/parsing.rs b/tests/ast_tests/parsing.rs index 8c01cf77e..834f55a50 100644 --- a/tests/ast_tests/parsing.rs +++ b/tests/ast_tests/parsing.rs @@ -376,3 +376,103 @@ fn phony_and_always_flags() -> Result<()> { } Ok(()) } + +#[rstest] +fn dependency_order_omission_defaults_to_parallel() -> Result<()> { + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: all + command: echo done + deps: + - check-fmt + - test + "#; + let manifest = parse_manifest(yaml)?; + let target = manifest.targets.first().context("expected target entry")?; + ensure!( + target.dependency_order == netsuke::ast::DependencyOrder::Parallel, + "omission should default to parallel, got {:?}", + target.dependency_order + ); + Ok(()) +} + +#[rstest] +fn dependency_order_explicit_parallel_parses() -> Result<()> { + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: all + command: echo done + dependency_order: parallel + deps: + - check-fmt + "#; + let manifest = parse_manifest(yaml)?; + let target = manifest.targets.first().context("expected target entry")?; + ensure!( + target.dependency_order == netsuke::ast::DependencyOrder::Parallel, + "explicit parallel should parse as Parallel, got {:?}", + target.dependency_order + ); + Ok(()) +} + +#[rstest] +fn dependency_order_explicit_serial_parses() -> Result<()> { + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: all + command: echo done + dependency_order: serial + deps: + - check-fmt + - lint + - test + "#; + let manifest = parse_manifest(yaml)?; + let target = manifest.targets.first().context("expected target entry")?; + ensure!( + target.dependency_order == netsuke::ast::DependencyOrder::Serial, + "serial should parse as Serial, got {:?}", + target.dependency_order + ); + Ok(()) +} + +#[rstest] +fn dependency_order_unknown_value_rejected() -> Result<()> { + let yaml = r#" + netsuke_version: "1.0.0" + targets: + - name: all + command: echo done + dependency_order: sequential + "#; + ensure!( + parse_manifest(yaml).is_err(), + "unknown dependency_order value should be rejected" + ); + Ok(()) +} + +#[rstest] +fn dependency_order_unknown_value_rejected_for_actions() -> Result<()> { + let yaml = r#" + netsuke_version: "1.0.0" + actions: + - name: setup + command: echo hi + dependency_order: sequential + targets: + - name: done + command: echo done + "#; + ensure!( + parse_manifest(yaml).is_err(), + "actions with unknown dependency_order value should be rejected" + ); + Ok(()) +} diff --git a/tests/ir_from_manifest_tests.rs b/tests/ir_from_manifest_tests.rs index fd8f7edd7..368934aac 100644 --- a/tests/ir_from_manifest_tests.rs +++ b/tests/ir_from_manifest_tests.rs @@ -11,7 +11,7 @@ use anyhow::{Context, Result, bail, ensure}; use camino::Utf8PathBuf; use netsuke::{ ast::Recipe, - ir::{BuildGraph, IrGenError}, + ir::{BuildGraph, DependencyOrder, IrGenError}, manifest, ninja_gen, }; use rstest::rstest; @@ -416,3 +416,73 @@ fn manifest_error_cases( } Ok(()) } + +#[rstest] +#[case::target_serial(concat!( + "netsuke_version: '1.0.0'\n", + "targets:\n", + " - name: all\n", + " dependency_order: serial\n", + " deps: [check-fmt, lint, test]\n", + " command: echo $out\n", +), "all", false)] +#[case::action_serial(concat!( + "netsuke_version: '1.0.0'\n", + "actions:\n", + " - name: gate\n", + " dependency_order: serial\n", + " deps: [fmt, clippy]\n", + " command: echo $out\n", + "targets: []\n", +), "gate", true)] +fn serial_dependency_order_survives_lowering( + #[case] yaml: &str, + #[case] output: &str, + #[case] expected_phony: bool, +) -> Result<()> { + let manifest = manifest::from_str(yaml)?; + let graph = BuildGraph::from_manifest(&manifest).context("expected graph generation")?; + let edge = graph + .targets + .get(&Utf8PathBuf::from(output)) + .with_context(|| format!("expected edge for {output}"))?; + ensure!( + edge.dependency_order == DependencyOrder::Serial, + "expected serial dependency order for {output}, got {:?}", + edge.dependency_order + ); + ensure!( + edge.phony == expected_phony, + "unexpected phony flag for {output}: {}", + edge.phony + ); + Ok(()) +} + +#[rstest] +fn parallel_dependency_order_lowering_is_default() -> Result<()> { + let yaml = concat!( + "netsuke_version: '1.0.0'\n", + "targets:\n", + " - name: all\n", + " deps: [check-fmt, lint]\n", + " command: echo $out\n", + ); + let manifest = manifest::from_str(yaml)?; + let graph = BuildGraph::from_manifest(&manifest).context("expected graph generation")?; + let edge = graph + .targets + .get(&Utf8PathBuf::from("all")) + .context("expected edge for all")?; + ensure!( + edge.dependency_order == DependencyOrder::Parallel, + "omission should default to parallel, got {:?}", + edge.dependency_order + ); + ensure!( + edge.implicit_deps == vec![Utf8PathBuf::from("check-fmt"), Utf8PathBuf::from("lint")], + "declaration order must be preserved through lowering: {:?}", + edge.implicit_deps + ); + Ok(()) +} diff --git a/tests/ir_tests.rs b/tests/ir_tests.rs index d059f7619..f911aa1a8 100644 --- a/tests/ir_tests.rs +++ b/tests/ir_tests.rs @@ -34,6 +34,7 @@ fn create_action_and_edge() { action_id: "id".into(), inputs: vec![Utf8PathBuf::from("in")], implicit_deps: Vec::new(), + dependency_order: netsuke::ast::DependencyOrder::Parallel, explicit_outputs: vec![Utf8PathBuf::from("out")], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), @@ -92,6 +93,7 @@ fn build_graph_duplicate_targets() { action_id: "a".into(), inputs: vec![Utf8PathBuf::from("in")], implicit_deps: Vec::new(), + dependency_order: netsuke::ast::DependencyOrder::Parallel, explicit_outputs: vec![Utf8PathBuf::from("out")], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), @@ -102,6 +104,7 @@ fn build_graph_duplicate_targets() { action_id: "a".into(), inputs: vec![Utf8PathBuf::from("in")], implicit_deps: Vec::new(), + dependency_order: netsuke::ast::DependencyOrder::Parallel, explicit_outputs: vec![Utf8PathBuf::from("out")], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), diff --git a/tests/ninja_gen_integration_tests.rs b/tests/ninja_gen_integration_tests.rs index b134f322a..dfb298347 100644 --- a/tests/ninja_gen_integration_tests.rs +++ b/tests/ninja_gen_integration_tests.rs @@ -36,6 +36,7 @@ fn ninja_integration_setup() -> Option { action_id: "script".into(), inputs: Vec::new(), implicit_deps: Vec::new(), + dependency_order: netsuke::ast::DependencyOrder::Parallel, explicit_outputs: vec![Utf8PathBuf::from("out")], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), @@ -59,6 +60,7 @@ fn ninja_integration_setup() -> Option { action_id: "percent".into(), inputs: Vec::new(), implicit_deps: Vec::new(), + dependency_order: netsuke::ast::DependencyOrder::Parallel, explicit_outputs: vec![Utf8PathBuf::from("out")], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), @@ -82,6 +84,7 @@ fn ninja_integration_setup() -> Option { action_id: "tick".into(), inputs: Vec::new(), implicit_deps: Vec::new(), + dependency_order: netsuke::ast::DependencyOrder::Parallel, explicit_outputs: vec![Utf8PathBuf::from("out")], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), @@ -105,6 +108,7 @@ fn ninja_integration_setup() -> Option { action_id: "hello".into(), inputs: Vec::new(), implicit_deps: Vec::new(), + dependency_order: netsuke::ast::DependencyOrder::Parallel, explicit_outputs: vec![Utf8PathBuf::from("say-hello")], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), @@ -399,6 +403,7 @@ fn errors_when_action_missing() -> Result<()> { action_id: "missing".into(), inputs: Vec::new(), implicit_deps: Vec::new(), + dependency_order: netsuke::ast::DependencyOrder::Parallel, explicit_outputs: vec![Utf8PathBuf::from("out")], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), @@ -441,6 +446,7 @@ fn generate_format_error() -> Result<()> { action_id: "a".into(), inputs: Vec::new(), implicit_deps: Vec::new(), + dependency_order: netsuke::ast::DependencyOrder::Parallel, explicit_outputs: vec![Utf8PathBuf::from("out")], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), diff --git a/tests/ninja_gen_unit_tests.rs b/tests/ninja_gen_unit_tests.rs index c10d00cf7..74313aaaa 100644 --- a/tests/ninja_gen_unit_tests.rs +++ b/tests/ninja_gen_unit_tests.rs @@ -25,6 +25,7 @@ use rstest::rstest; action_id: "a".into(), inputs: vec![Utf8PathBuf::from("in")], implicit_deps: Vec::new(), + dependency_order: netsuke::ast::DependencyOrder::Parallel, explicit_outputs: vec![Utf8PathBuf::from("out")], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), @@ -51,6 +52,7 @@ use rstest::rstest; action_id: "compile".into(), inputs: vec![Utf8PathBuf::from("a.c"), Utf8PathBuf::from("b.c")], implicit_deps: Vec::new(), + dependency_order: netsuke::ast::DependencyOrder::Parallel, explicit_outputs: vec![Utf8PathBuf::from("ab.o")], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), @@ -77,6 +79,7 @@ use rstest::rstest; action_id: "b".into(), inputs: vec![Utf8PathBuf::from("in")], implicit_deps: Vec::new(), + dependency_order: netsuke::ast::DependencyOrder::Parallel, explicit_outputs: vec![Utf8PathBuf::from("out"), Utf8PathBuf::from("log")], implicit_outputs: vec![Utf8PathBuf::from("out.d")], order_only_deps: vec![Utf8PathBuf::from("stamp")], @@ -103,6 +106,7 @@ use rstest::rstest; action_id: "b".into(), inputs: vec![Utf8PathBuf::from("in")], implicit_deps: vec![Utf8PathBuf::from("dep")], + dependency_order: netsuke::ast::DependencyOrder::Parallel, explicit_outputs: vec![Utf8PathBuf::from("out")], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), @@ -129,6 +133,7 @@ use rstest::rstest; action_id: "b".into(), inputs: Vec::new(), implicit_deps: vec![Utf8PathBuf::from("dep")], + dependency_order: netsuke::ast::DependencyOrder::Parallel, explicit_outputs: vec![Utf8PathBuf::from("out")], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), @@ -155,6 +160,7 @@ use rstest::rstest; action_id: "b".into(), inputs: vec![Utf8PathBuf::from("in")], implicit_deps: vec![Utf8PathBuf::from("dep")], + dependency_order: netsuke::ast::DependencyOrder::Parallel, explicit_outputs: vec![Utf8PathBuf::from("out")], implicit_outputs: Vec::new(), order_only_deps: vec![Utf8PathBuf::from("stamp")], @@ -181,6 +187,7 @@ use rstest::rstest; action_id: "phony".into(), inputs: Vec::new(), implicit_deps: vec![Utf8PathBuf::from("dep")], + dependency_order: netsuke::ast::DependencyOrder::Parallel, explicit_outputs: vec![Utf8PathBuf::from("phony_action")], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), @@ -240,6 +247,7 @@ fn generate_multiline_script_snapshot() { action_id: "script".into(), inputs: Vec::new(), implicit_deps: Vec::new(), + dependency_order: netsuke::ast::DependencyOrder::Parallel, explicit_outputs: vec![Utf8PathBuf::from("out")], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), From e45b2b9d323269715f536aca423d60e2154d6a8b Mon Sep 17 00:00:00 2001 From: leynos Date: Mon, 10 Aug 2026 22:09:31 +0200 Subject: [PATCH 04/69] Generate Ninja dyndep bundles for serial dependency ordering (#552) Introduce a generated-bundle API so a `serial` `deps` list can be expressed as a single Ninja invocation. `generate_bundle` lowers each multi-dependency serial edge into one phony gate and content-addressed dyndep sidecar per dependency under the reserved `.netsuke/serial` and `.netsuke/dyndep` namespaces, emits `ninja_required_version = 1.10` only when gates exist, and keeps ordinary parallel graphs byte-for-byte identical to string-only generation. String-only `generate`/`generate_into` now refuse serial graphs with a localized `DyndepFilesRequired` error before writing any output, and a user output that collides with the reserved namespace is rejected. Add localization keys for both errors across every shipped catalogue and split `src/ninja_gen.rs` into a directory module so no file exceeds the repository line ceiling. Cover the staging rules with unit and integration tests: sidecar count and ordering, version-floor gating, the no-sidecar cases for parallel and single-element serial lists, per-stage sidecars for repeated dependencies, and the reserved-path error. --- ...ndency-ordering-for-actions-and-targets.md | 11 +- locales/ar/messages.ftl | 3 + locales/cs/messages.ftl | 3 + locales/cy/messages.ftl | 3 + locales/da/messages.ftl | 3 + locales/de/messages.ftl | 3 + locales/el/messages.ftl | 3 + locales/en-GB/messages.ftl | 3 + locales/en-US/messages.ftl | 3 + locales/es-419/messages.ftl | 3 + locales/es-ES/messages.ftl | 3 + locales/fa/messages.ftl | 3 + locales/fi/messages.ftl | 3 + locales/fr/messages.ftl | 3 + locales/gd/messages.ftl | 3 + locales/he/messages.ftl | 3 + locales/hi/messages.ftl | 3 + locales/hu/messages.ftl | 3 + locales/id/messages.ftl | 3 + locales/it/messages.ftl | 3 + locales/ja/messages.ftl | 3 + locales/ko/messages.ftl | 3 + locales/nb/messages.ftl | 3 + locales/nl/messages.ftl | 3 + locales/pl/messages.ftl | 3 + locales/pt-BR/messages.ftl | 3 + locales/pt-PT/messages.ftl | 3 + locales/ro/messages.ftl | 3 + locales/ru/messages.ftl | 3 + locales/sv/messages.ftl | 3 + locales/th/messages.ftl | 3 + locales/tr/messages.ftl | 3 + locales/uk/messages.ftl | 3 + locales/vi/messages.ftl | 3 + locales/zh-Hans/messages.ftl | 3 + locales/zh-Hant/messages.ftl | 3 + src/localization/keys.rs | 2 + src/ninja_gen/dyndep.rs | 355 ++++++++++++++++++ src/ninja_gen/dyndep_tests.rs | 185 +++++++++ src/{ninja_gen.rs => ninja_gen/mod.rs} | 54 ++- src/ninja_gen/tests.rs | 95 +++++ src/ninja_gen_error.rs | 15 + tests/ninja_gen_integration_tests.rs | 90 +++++ 43 files changed, 901 insertions(+), 11 deletions(-) create mode 100644 src/ninja_gen/dyndep.rs create mode 100644 src/ninja_gen/dyndep_tests.rs rename src/{ninja_gen.rs => ninja_gen/mod.rs} (87%) create mode 100644 src/ninja_gen/tests.rs diff --git a/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md b/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md index 242ce89bd..1b8f7f2cc 100644 --- a/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md +++ b/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md @@ -183,7 +183,16 @@ criterion in issue #552 and all repository gates pass. re-exported through `ir`. Updated every direct `BuildEdge` literal, doctest, and fixture to compile. `cargo check --all-targets` and 739 lib + touched integration tests pass. -- [ ] Implement deterministic Ninja bundle and dyndep lowering. +- [x] (2026-08-10) Implemented deterministic Ninja bundle and dyndep lowering: + the new `src/ninja_gen/dyndep.rs` submodule adds `GeneratedNinja` (main + text plus content-addressed `GeneratedDyndep` sidecars) and + `generate_bundle`. Serial multi-dependency edges lower into one phony gate + and sidecar per dependency; the version floor is emitted only when gates + exist; sidecars are content-addressed beneath `.netsuke/dyndep`; and + string-only generation returns `NinjaGenError::DyndepFilesRequired` + without writing partial output. Added reserved-namespace collision errors + and localization keys across all 35 catalogues. Unit, integration, and + doctests pass. - [ ] Implement atomic dyndep sidecar materialization in every CLI path. - [ ] Complete user, design, developer, layout, roadmap, and ADR documentation. - [ ] Run focused verification and all repository gates. diff --git a/locales/ar/messages.ftl b/locales/ar/messages.ftl index 3f59007a8..925b2a92a 100644 --- a/locales/ar/messages.ftl +++ b/locales/ar/messages.ftl @@ -172,6 +172,8 @@ ir.invalid_command = إقحام غير صالح داخل الأمر: { $snippet # أخطاء توليد ملفات Ninja. ninja_gen.missing_action = الإجراء «{ $id }» الذي تشير إليه حافة بناء مفقود. ninja_gen.format = تعذّر تنسيق مخرجات ملف بيانات Ninja. +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. # التحقق من أنماط المضيفين. host_pattern.empty = يجب ألّا يكون نمط المضيف فارغًا. @@ -415,3 +417,4 @@ example.errors_found = { $count -> [many] عُثر على { $count } خطأً. *[other] عُثر على { $count } خطأ. } + diff --git a/locales/cs/messages.ftl b/locales/cs/messages.ftl index 2c9cf8ee5..77ac51e11 100644 --- a/locales/cs/messages.ftl +++ b/locales/cs/messages.ftl @@ -172,6 +172,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.format = Výstup manifestu Ninja se nepodařilo naformátovat. +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. # Ověření vzorů hostitelů. host_pattern.empty = Vzor hostitele nesmí být prázdný. @@ -412,3 +414,4 @@ example.errors_found = { $count -> [many] Nalezeno { $count } chyby. *[other] Nalezeno { $count } chyb. } + diff --git a/locales/cy/messages.ftl b/locales/cy/messages.ftl index 5efbe8e86..c71b799aa 100644 --- a/locales/cy/messages.ftl +++ b/locales/cy/messages.ftl @@ -172,6 +172,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.format = Methwyd â fformatio allbwn y maniffest Ninja. +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. # Dilysu patrymau gwesteiwyr. host_pattern.empty = Rhaid i'r patrwm gwesteiwr beidio â bod yn wag. @@ -415,3 +417,4 @@ example.errors_found = { $count -> [many] Cafwyd hyd i { $count } gwall. *[other] Cafwyd hyd i { $count } gwall. } + diff --git a/locales/da/messages.ftl b/locales/da/messages.ftl index e830dff73..f03b15685 100644 --- a/locales/da/messages.ftl +++ b/locales/da/messages.ftl @@ -172,6 +172,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.format = Ninja-manifestets output kunne ikke formateres. +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. # Validering af værtsmønstre. host_pattern.empty = Værtsmønsteret må ikke være tomt. @@ -407,3 +409,4 @@ example.errors_found = { $count -> [one] { $count } fejl fundet. *[other] { $count } fejl fundet. } + diff --git a/locales/de/messages.ftl b/locales/de/messages.ftl index 6ab8578dd..57281e672 100644 --- a/locales/de/messages.ftl +++ b/locales/de/messages.ftl @@ -172,6 +172,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.format = Die Ausgabe des Ninja-Manifests konnte nicht formatiert werden. +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. # Validierung von Host-Mustern. host_pattern.empty = Das Host-Muster darf nicht leer sein. @@ -407,3 +409,4 @@ example.errors_found = { $count -> [one] { $count } Fehler gefunden. *[other] { $count } Fehler gefunden. } + diff --git a/locales/el/messages.ftl b/locales/el/messages.ftl index 8c8eb1128..691a9b1c8 100644 --- a/locales/el/messages.ftl +++ b/locales/el/messages.ftl @@ -173,6 +173,8 @@ ir.invalid_command = Μη έγκυρη παρεμβολή στην εντολή: # Σφάλματα παραγωγής αρχείων Ninja. ninja_gen.missing_action = Λείπει η ενέργεια «{ $id }» στην οποία παραπέμπει ακμή δόμησης. ninja_gen.format = Δεν ήταν δυνατή η μορφοποίηση της εξόδου του δηλωτικού Ninja. +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. # Έλεγχος μοτίβων κόμβων. host_pattern.empty = Το μοτίβο κόμβου δεν πρέπει να είναι κενό. @@ -409,3 +411,4 @@ example.errors_found = { $count -> [one] Βρέθηκε { $count } σφάλμα. *[other] Βρέθηκαν { $count } σφάλματα. } + diff --git a/locales/en-GB/messages.ftl b/locales/en-GB/messages.ftl index 1fc815be5..cb5e4ae20 100644 --- a/locales/en-GB/messages.ftl +++ b/locales/en-GB/messages.ftl @@ -172,6 +172,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.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. # Host pattern validation. host_pattern.empty = Host pattern must not be empty. @@ -408,3 +410,4 @@ example.errors_found = { $count -> [one] { $count } error found. *[other] { $count } errors found. } + diff --git a/locales/en-US/messages.ftl b/locales/en-US/messages.ftl index 2a53bbd85..3d29ca723 100644 --- a/locales/en-US/messages.ftl +++ b/locales/en-US/messages.ftl @@ -172,6 +172,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.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. # Host pattern validation. host_pattern.empty = Host pattern must not be empty. @@ -412,3 +414,4 @@ example.errors_found = { $count -> [one] { $count } error found. *[other] { $count } errors found. } + diff --git a/locales/es-419/messages.ftl b/locales/es-419/messages.ftl index e35fa515c..550875dd2 100644 --- a/locales/es-419/messages.ftl +++ b/locales/es-419/messages.ftl @@ -173,6 +173,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.format = No se pudo dar formato a la salida del manifiesto de Ninja. +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. # Validación de patrones de host. host_pattern.empty = El patrón de host no debe estar vacío. @@ -410,3 +412,4 @@ example.errors_found = { $count -> [one] Se encontró { $count } error. *[other] Se encontraron { $count } errores. } + diff --git a/locales/es-ES/messages.ftl b/locales/es-ES/messages.ftl index 57ac96f60..042c38a6d 100644 --- a/locales/es-ES/messages.ftl +++ b/locales/es-ES/messages.ftl @@ -172,6 +172,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.format = No se pudo formatear la salida del manifiesto Ninja. +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. # Validación de patrones de host. host_pattern.empty = El patrón de host no debe estar vacío. @@ -411,3 +413,4 @@ example.errors_found = { $count -> [one] Se encontró { $count } error. *[other] Se encontraron { $count } errores. } + diff --git a/locales/fa/messages.ftl b/locales/fa/messages.ftl index b866d87e9..097377857 100644 --- a/locales/fa/messages.ftl +++ b/locales/fa/messages.ftl @@ -172,6 +172,8 @@ ir.invalid_command = درج نامعتبر در فرمان: { $snippet }. # خطاهای تولید پرونده‌های Ninja. ninja_gen.missing_action = کنش «{ $id }» که یک یال ساخت به آن ارجاع می‌دهد وجود ندارد. ninja_gen.format = قالب‌بندی خروجی مانیفست Ninja ممکن نشد. +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. # اعتبارسنجی الگوهای میزبان. host_pattern.empty = الگوی میزبان نباید تهی باشد. @@ -407,3 +409,4 @@ example.errors_found = { $count -> [one] ‏{ $count } خطا یافت شد. *[other] ‏{ $count } خطا یافت شد. } + diff --git a/locales/fi/messages.ftl b/locales/fi/messages.ftl index 232015b8b..962be1085 100644 --- a/locales/fi/messages.ftl +++ b/locales/fi/messages.ftl @@ -172,6 +172,8 @@ ir.invalid_command = Virheellinen komennon sijoitus: { $snippet }. # Ninja-generoinnin virheet. ninja_gen.missing_action = Toiminto ”{ $id }”, johon koontikaari viittaa, puuttuu. ninja_gen.format = Ninja-manifestin tulostetta ei voitu muotoilla. +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. # Isäntähahmojen tarkistus. host_pattern.empty = Isäntähahmo ei saa olla tyhjä. @@ -409,3 +411,4 @@ example.errors_found = { $count -> [one] Löytyi { $count } virhe. *[other] Löytyi { $count } virhettä. } + diff --git a/locales/fr/messages.ftl b/locales/fr/messages.ftl index 3d123486e..6e66c5866 100644 --- a/locales/fr/messages.ftl +++ b/locales/fr/messages.ftl @@ -173,6 +173,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.format = Impossible de formater la sortie du manifeste Ninja. +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. # Validation des motifs d'hôte. host_pattern.empty = Le motif d'hôte ne doit pas être vide. @@ -409,3 +411,4 @@ example.errors_found = { $count -> [one] { $count } erreur trouvée. *[other] { $count } erreurs trouvées. } + diff --git a/locales/gd/messages.ftl b/locales/gd/messages.ftl index 6a9244e99..d27af2199 100644 --- a/locales/gd/messages.ftl +++ b/locales/gd/messages.ftl @@ -172,6 +172,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.format = Cha b' urrainnear às-chur an fhoirm-liosta Ninja fhòrmatadh. +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. # Dearbhadh phàtranan òstair. host_pattern.empty = Chan fhaod pàtran an òstair a bhith falamh. @@ -412,3 +414,4 @@ example.errors_found = { $count -> [few] Chaidh { $count } mearachdan a lorg. *[other] Chaidh { $count } mearachd a lorg. } + diff --git a/locales/he/messages.ftl b/locales/he/messages.ftl index b24c85a39..d174ae7bb 100644 --- a/locales/he/messages.ftl +++ b/locales/he/messages.ftl @@ -172,6 +172,8 @@ ir.invalid_command = שיבוץ לא תקין בפקודה: { $snippet }. # שגיאות ביצירת קובצי Ninja. ninja_gen.missing_action = הפעולה „{ $id }” שאליה מפנה קשת בנייה חסרה. ninja_gen.format = לא ניתן היה לעצב את פלט מניפסט Ninja. +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. # אימות תבניות מארח. host_pattern.empty = תבנית המארח אינה יכולה להיות ריקה. @@ -412,3 +414,4 @@ example.errors_found = { $count -> [many] נמצאו { $count } שגיאות. *[other] נמצאו { $count } שגיאות. } + diff --git a/locales/hi/messages.ftl b/locales/hi/messages.ftl index 96c2a6661..e00e8d5ad 100644 --- a/locales/hi/messages.ftl +++ b/locales/hi/messages.ftl @@ -172,6 +172,8 @@ ir.invalid_command = आदेश में अमान्य प्रक् # Ninja निर्माण की त्रुटियाँ। ninja_gen.missing_action = किसी बिल्ड कोर द्वारा संदर्भित क्रिया “{ $id }” अनुपस्थित है। ninja_gen.format = Ninja मैनिफ़ेस्ट का निर्गम स्वरूपित नहीं किया जा सका। +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. # होस्ट प्रतिरूपों का सत्यापन। host_pattern.empty = होस्ट प्रतिरूप रिक्त नहीं होना चाहिए। @@ -410,3 +412,4 @@ example.errors_found = { $count -> [one] { $count } त्रुटि मिली। *[other] { $count } त्रुटियाँ मिलीं। } + diff --git a/locales/hu/messages.ftl b/locales/hu/messages.ftl index 02504cc68..347641df4 100644 --- a/locales/hu/messages.ftl +++ b/locales/hu/messages.ftl @@ -172,6 +172,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.format = A Ninja-jegyzék kimenetét nem sikerült formázni. +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. # A gépminták ellenőrzése. host_pattern.empty = A gépminta nem lehet üres. @@ -409,3 +411,4 @@ example.errors_found = { $count -> [one] { $count } hiba található. *[other] { $count } hiba található. } + diff --git a/locales/id/messages.ftl b/locales/id/messages.ftl index d37ae60f9..2d09f91e5 100644 --- a/locales/id/messages.ftl +++ b/locales/id/messages.ftl @@ -172,6 +172,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.format = Keluaran manifes Ninja tidak dapat diformat. +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. # Validasi pola host. host_pattern.empty = Pola host tidak boleh kosong. @@ -406,3 +408,4 @@ example.errors_found = { $count -> [0] Tidak ada galat yang ditemukan. *[other] { $count } galat ditemukan. } + diff --git a/locales/it/messages.ftl b/locales/it/messages.ftl index eab3286b6..f963973cf 100644 --- a/locales/it/messages.ftl +++ b/locales/it/messages.ftl @@ -173,6 +173,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.format = Impossibile formattare l'output del manifest Ninja. +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. # Validazione dei pattern host. host_pattern.empty = Il pattern host non deve essere vuoto. @@ -408,3 +410,4 @@ example.errors_found = { $count -> [one] Trovato { $count } errore. *[other] Trovati { $count } errori. } + diff --git a/locales/ja/messages.ftl b/locales/ja/messages.ftl index 47db7c293..37da8d008 100644 --- a/locales/ja/messages.ftl +++ b/locales/ja/messages.ftl @@ -172,6 +172,8 @@ ir.invalid_command = コマンドの補間が無効です: { $snippet }。 # Ninja 生成のエラー。 ninja_gen.missing_action = ビルド辺が参照するアクション「{ $id }」がありません。 ninja_gen.format = Ninja マニフェストの出力を整形できませんでした。 +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. # ホストパターンの検証。 host_pattern.empty = ホストパターンを空にすることはできません。 @@ -405,3 +407,4 @@ example.errors_found = { $count -> [0] エラーは見つかりませんでした。 *[other] { $count } 件のエラーが見つかりました。 } + diff --git a/locales/ko/messages.ftl b/locales/ko/messages.ftl index e24c185c6..03c6d2d74 100644 --- a/locales/ko/messages.ftl +++ b/locales/ko/messages.ftl @@ -172,6 +172,8 @@ ir.invalid_command = 명령의 보간이 잘못되었습니다: { $snippet }. # Ninja 생성 오류. ninja_gen.missing_action = 빌드 간선이 참조하는 동작 '{ $id }'이(가) 없습니다. ninja_gen.format = Ninja 매니페스트 출력의 서식을 지정하지 못했습니다. +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. # 호스트 패턴 검증. host_pattern.empty = 호스트 패턴은 비어 있을 수 없습니다. @@ -405,3 +407,4 @@ example.errors_found = { $count -> [0] 오류를 찾지 못했습니다. *[other] 오류 { $count }개를 찾았습니다. } + diff --git a/locales/nb/messages.ftl b/locales/nb/messages.ftl index 8c623d0f0..747996dec 100644 --- a/locales/nb/messages.ftl +++ b/locales/nb/messages.ftl @@ -172,6 +172,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.format = Utdataene fra Ninja-manifestet kunne ikke formateres. +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. # Validering av vertsmønstre. host_pattern.empty = Vertsmønsteret kan ikke være tomt. @@ -407,3 +409,4 @@ example.errors_found = { $count -> [one] { $count } feil funnet. *[other] { $count } feil funnet. } + diff --git a/locales/nl/messages.ftl b/locales/nl/messages.ftl index c77b47bf4..22f32b489 100644 --- a/locales/nl/messages.ftl +++ b/locales/nl/messages.ftl @@ -172,6 +172,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.format = De uitvoer van het Ninja-manifest kon niet worden opgemaakt. +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. # Validatie van hostpatronen. host_pattern.empty = Het hostpatroon mag niet leeg zijn. @@ -408,3 +410,4 @@ example.errors_found = { $count -> [one] { $count } fout gevonden. *[other] { $count } fouten gevonden. } + diff --git a/locales/pl/messages.ftl b/locales/pl/messages.ftl index 5441cfff7..b7b538c60 100644 --- a/locales/pl/messages.ftl +++ b/locales/pl/messages.ftl @@ -172,6 +172,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.format = Nie udało się sformatować wyjścia manifestu Ninja. +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. # Walidacja wzorców hostów. host_pattern.empty = Wzorzec hosta nie może być pusty. @@ -413,3 +415,4 @@ example.errors_found = { $count -> [many] Znaleziono { $count } błędów. *[other] Znaleziono { $count } błędu. } + diff --git a/locales/pt-BR/messages.ftl b/locales/pt-BR/messages.ftl index 80162d11b..804bc2aec 100644 --- a/locales/pt-BR/messages.ftl +++ b/locales/pt-BR/messages.ftl @@ -173,6 +173,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.format = Não foi possível formatar a saída do manifesto do Ninja. +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. # Validação de padrões de host. host_pattern.empty = O padrão de host não pode estar vazio. @@ -409,3 +411,4 @@ example.errors_found = { $count -> [one] { $count } erro encontrado. *[other] { $count } erros encontrados. } + diff --git a/locales/pt-PT/messages.ftl b/locales/pt-PT/messages.ftl index 5d864daed..39aee75c6 100644 --- a/locales/pt-PT/messages.ftl +++ b/locales/pt-PT/messages.ftl @@ -173,6 +173,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.format = Não foi possível formatar a saída do manifesto Ninja. +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. # Validação de padrões de anfitrião. host_pattern.empty = O padrão de anfitrião não pode estar vazio. @@ -409,3 +411,4 @@ example.errors_found = { $count -> [one] Foi encontrado { $count } erro. *[other] Foram encontrados { $count } erros. } + diff --git a/locales/ro/messages.ftl b/locales/ro/messages.ftl index d9ff226ca..5dc343ca6 100644 --- a/locales/ro/messages.ftl +++ b/locales/ro/messages.ftl @@ -172,6 +172,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.format = Ieșirea manifestului Ninja nu a putut fi formatată. +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. # Validarea tiparelor de gazdă. host_pattern.empty = Tiparul de gazdă nu trebuie să fie gol. @@ -411,3 +413,4 @@ example.errors_found = { $count -> [few] S-au găsit { $count } erori. *[other] S-au găsit { $count } de erori. } + diff --git a/locales/ru/messages.ftl b/locales/ru/messages.ftl index 3d3c20518..7f5c6c12d 100644 --- a/locales/ru/messages.ftl +++ b/locales/ru/messages.ftl @@ -172,6 +172,8 @@ ir.invalid_command = Некорректная подстановка в кома # Ошибки генерации файлов Ninja. ninja_gen.missing_action = Отсутствует действие «{ $id }», на которое ссылается ребро сборки. ninja_gen.format = Не удалось отформатировать вывод манифеста Ninja. +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. # Проверка шаблонов узлов. host_pattern.empty = Шаблон узла не должен быть пустым. @@ -414,3 +416,4 @@ example.errors_found = { $count -> [many] Найдено { $count } ошибок. *[other] Найдено { $count } ошибки. } + diff --git a/locales/sv/messages.ftl b/locales/sv/messages.ftl index ee0b824ad..354387ec0 100644 --- a/locales/sv/messages.ftl +++ b/locales/sv/messages.ftl @@ -172,6 +172,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.format = Ninja-manifestets utdata kunde inte formateras. +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. # Validering av värdmönster. host_pattern.empty = Värdmönstret får inte vara tomt. @@ -407,3 +409,4 @@ example.errors_found = { $count -> [one] { $count } fel hittades. *[other] { $count } fel hittades. } + diff --git a/locales/th/messages.ftl b/locales/th/messages.ftl index 741f246e1..e798b23b6 100644 --- a/locales/th/messages.ftl +++ b/locales/th/messages.ftl @@ -172,6 +172,8 @@ ir.invalid_command = การแทรกค่าในคำสั่งไ # ข้อผิดพลาดในการสร้างไฟล์ Ninja ninja_gen.missing_action = ไม่มีการกระทำ “{ $id }” ที่เส้นเชื่อมของการสร้างอ้างถึง ninja_gen.format = จัดรูปแบบผลลัพธ์ของไฟล์รายการ Ninja ไม่สำเร็จ +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. # การตรวจสอบรูปแบบโฮสต์ host_pattern.empty = รูปแบบโฮสต์ต้องไม่ว่างเปล่า @@ -405,3 +407,4 @@ example.errors_found = { $count -> [0] ไม่พบข้อผิดพลาด *[other] พบข้อผิดพลาด { $count } รายการ } + diff --git a/locales/tr/messages.ftl b/locales/tr/messages.ftl index 2398f6844..3bd2c29c7 100644 --- a/locales/tr/messages.ftl +++ b/locales/tr/messages.ftl @@ -172,6 +172,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.format = Ninja bildiriminin çıktısı biçimlendirilemedi. +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. # Makine deseni doğrulaması. host_pattern.empty = Makine deseni boş olmamalıdır. @@ -408,3 +410,4 @@ example.errors_found = { $count -> [one] { $count } hata bulundu. *[other] { $count } hata bulundu. } + diff --git a/locales/uk/messages.ftl b/locales/uk/messages.ftl index 8b3137433..4892c049c 100644 --- a/locales/uk/messages.ftl +++ b/locales/uk/messages.ftl @@ -172,6 +172,8 @@ ir.invalid_command = Некоректна підстановка в команд # Помилки створення файлів Ninja. ninja_gen.missing_action = Відсутня дія «{ $id }», на яку посилається ребро збирання. ninja_gen.format = Не вдалося відформатувати вивід маніфесту Ninja. +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. # Перевірка шаблонів вузлів. host_pattern.empty = Шаблон вузла не повинен бути порожнім. @@ -414,3 +416,4 @@ example.errors_found = { $count -> [many] Знайдено { $count } помилок. *[other] Знайдено { $count } помилки. } + diff --git a/locales/vi/messages.ftl b/locales/vi/messages.ftl index c1e9327e0..bddca3b49 100644 --- a/locales/vi/messages.ftl +++ b/locales/vi/messages.ftl @@ -172,6 +172,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.format = Không định dạng được đầu ra của tệp kê khai Ninja. +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. # Kiểm tra mẫu máy chủ. host_pattern.empty = Mẫu máy chủ không được để trống. @@ -405,3 +407,4 @@ example.errors_found = { $count -> [0] Không tìm thấy lỗi nào. *[other] Tìm thấy { $count } lỗi. } + diff --git a/locales/zh-Hans/messages.ftl b/locales/zh-Hans/messages.ftl index abaa9f664..9ef8cabc9 100644 --- a/locales/zh-Hans/messages.ftl +++ b/locales/zh-Hans/messages.ftl @@ -171,6 +171,8 @@ ir.invalid_command = 命令中的插值无效:{ $snippet }。 # Ninja 生成错误。 ninja_gen.missing_action = 缺少构建边引用的动作“{ $id }”。 ninja_gen.format = 无法格式化 Ninja 清单的输出。 +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. # 主机模式校验。 host_pattern.empty = 主机模式不能为空。 @@ -404,3 +406,4 @@ example.errors_found = { $count -> [0] 未发现错误。 *[other] 发现 { $count } 个错误。 } + diff --git a/locales/zh-Hant/messages.ftl b/locales/zh-Hant/messages.ftl index b30bfe583..51df86f05 100644 --- a/locales/zh-Hant/messages.ftl +++ b/locales/zh-Hant/messages.ftl @@ -171,6 +171,8 @@ ir.invalid_command = 命令中的插值無效:{ $snippet }。 # Ninja 產生錯誤。 ninja_gen.missing_action = 缺少建置邊所參照的動作「{ $id }」。 ninja_gen.format = 無法格式化 Ninja 資訊清單的輸出。 +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. # 主機樣式驗證。 host_pattern.empty = 主機樣式不得為空。 @@ -404,3 +406,4 @@ example.errors_found = { $count -> [0] 未發現錯誤。 *[other] 發現 { $count } 個錯誤。 } + diff --git a/src/localization/keys.rs b/src/localization/keys.rs index 6e79dc5d0..f0fc9a777 100644 --- a/src/localization/keys.rs +++ b/src/localization/keys.rs @@ -149,6 +149,8 @@ define_keys! { IR_INVALID_COMMAND => "ir.invalid_command", NINJA_GEN_MISSING_ACTION => "ninja_gen.missing_action", 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", HOST_PATTERN_EMPTY => "host_pattern.empty", HOST_PATTERN_CONTAINS_SCHEME => "host_pattern.contains_scheme", HOST_PATTERN_CONTAINS_SLASH => "host_pattern.contains_slash", diff --git a/src/ninja_gen/dyndep.rs b/src/ninja_gen/dyndep.rs new file mode 100644 index 000000000..cf3678065 --- /dev/null +++ b/src/ninja_gen/dyndep.rs @@ -0,0 +1,355 @@ +//! Staged Ninja dyndep lowering for serial dependency ordering. +//! +//! Netsuke keeps one top-level Ninja invocation. To make a `serial` `deps` +//! list execute in declaration order, this module lowers each serial edge +//! into a chain of phony gates plus content-addressed dyndep sidecars. Each +//! gate names one sidecar through Ninja `dyndep` binding; the sidecar +//! reveals exactly one real dependency. The next sidecar is not visible until +//! the preceding gate completes, so Ninja cannot schedule a later dependency +//! before an earlier one succeeds. +//! +//! The sidecars are immutable and content-addressed beneath `.netsuke/dyndep`, +//! and the gates live beneath `.netsuke/serial`. The runner materializes the +//! sidecars (and the main file) before invoking Ninja; string-only generation +//! rejects graphs that require a bundle. +//! +//! ```rust +//! use netsuke::ast::{Recipe, DependencyOrder}; +//! use netsuke::ir::{BuildEdge, BuildGraph}; +//! use netsuke::ninja_gen::generate_bundle; +//! use camino::Utf8PathBuf; +//! +//! let action = netsuke::ir::Action { +//! recipe: Recipe::Command { command: "echo done".into() }, +//! description: None, +//! depfile: None, +//! deps_format: None, +//! pool: None, +//! restat: false, +//! }; +//! let mut graph = BuildGraph::default(); +//! graph.actions.insert("a".into(), action); +//! graph.targets.insert( +//! Utf8PathBuf::from("all"), +//! BuildEdge { +//! action_id: "a".into(), +//! inputs: Vec::new(), +//! implicit_deps: vec![ +//! Utf8PathBuf::from("check-fmt"), +//! Utf8PathBuf::from("test"), +//! ], +//! dependency_order: DependencyOrder::Serial, +//! explicit_outputs: vec![Utf8PathBuf::from("all")], +//! implicit_outputs: Vec::new(), +//! order_only_deps: Vec::new(), +//! phony: false, +//! always: false, +//! }, +//! ); +//! let bundle = generate_bundle(&graph).expect("generate bundle"); +//! assert!(bundle.build_file().contains("ninja_required_version = 1.10")); +//! assert_eq!(bundle.dyndep_files().len(), 2); +//! ``` + +use crate::ast::DependencyOrder; +use crate::hex; +use crate::ir::{BuildEdge, BuildGraph}; +use crate::localization::{self, keys}; +use crate::ninja_gen::{NinjaGenError, join, path_key}; +use camino::Utf8PathBuf; +use sha2::{Digest, Sha256}; +use std::collections::HashSet; +use std::fmt::Write as _; + +/// Schema tag incorporated into every parent (gate-set) identity. +const PARENT_SCHEMA: &str = "netsuke-serial-v1"; +/// Format tag incorporated into every dyndep filename digest. +const DYNDEP_SCHEMA: &str = "netsuke-dyndep-v1"; +/// Reserved state namespace for serial gate paths. +const SERIAL_NAMESPACE: &str = ".netsuke/serial"; +/// Reserved state namespace for dyndep sidecar files. +const DYNDEP_NAMESPACE: &str = ".netsuke/dyndep"; + +/// One generated dyndep sidecar file inside a [`GeneratedNinja`] bundle. +/// +/// `relative_path` is relative to the effective Ninja working directory and +/// matches the path the main build file references. `content` is the full +/// Ninja-syntax dyndep document. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct GeneratedDyndep { + relative_path: Utf8PathBuf, + content: String, +} + +impl GeneratedDyndep { + /// Borrow the sidecar path relative to the effective Ninja working + /// directory. + #[must_use] + pub fn relative_path(&self) -> &Utf8PathBuf { + &self.relative_path + } + + /// Borrow the dyndep document content to materialize. + #[must_use] + pub fn content(&self) -> &str { + &self.content + } +} + +/// The complete generated Ninja artefact: the main build file text plus every +/// dyndep sidecar required to load and execute it. +/// +/// All paths are relative to the effective Ninja working directory. Do not +/// invoke Ninja on [`GeneratedNinja::build_file`] until every sidecar in +/// [`GeneratedNinja::dyndep_files`] has been materialized beside it. +#[derive(Debug, Clone)] +pub struct GeneratedNinja { + build_file: String, + dyndep_files: Vec, +} + +impl GeneratedNinja { + /// Borrow the main Ninja build file text. + #[must_use] + pub fn build_file(&self) -> &str { + &self.build_file + } + + /// Borrow the dyndep sidecars required by `build_file`. + #[must_use] + pub fn dyndep_files(&self) -> &[GeneratedDyndep] { + &self.dyndep_files + } + + /// Consume the bundle, returning the main file text and its sidecars. + #[must_use] + pub fn into_parts(self) -> (String, Vec) { + (self.build_file, self.dyndep_files) + } +} + +/// Generate a complete Ninja bundle for `graph`, materializing staged dyndep +/// sidecars for every multi-dependency serial edge. +/// +/// Ordinary parallel graphs produce a bundle with an empty sidecar list and a +/// main file identical to [`crate::ninja_gen::generate`]. +/// +/// # Errors +/// +/// 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. +pub fn generate_bundle(graph: &BuildGraph) -> Result { + reject_reserved_paths(graph)?; + let serial_present = graph_requires_dyndep(graph); + + let mut out = String::new(); + if serial_present { + writeln!(out, "ninja_required_version = 1.10\n").expect("write to String cannot fail"); + } + + let mut actions: Vec<_> = graph.actions.iter().collect(); + actions.sort_by_key(|(id, _)| *id); + for (id, action) in actions { + use crate::ninja_gen::NamedAction; + writeln!(out, "{}", NamedAction { id, action }).expect("write to String cannot fail"); + } + + let mut edges: Vec<_> = graph.targets.values().collect(); + edges.sort_by_key(|a| path_key(&a.explicit_outputs)); + let mut seen = HashSet::new(); + let mut dyndep_files: Vec = Vec::new(); + let mut staged_sidecars: HashSet = HashSet::new(); + + for edge in edges { + let key = path_key(&edge.explicit_outputs); + if !seen.insert(key.clone()) { + continue; + } + let action = + graph + .actions + .get(&edge.action_id) + .ok_or_else(|| NinjaGenError::MissingAction { + id: edge.action_id.clone(), + message: localization::message(keys::NINJA_GEN_MISSING_ACTION) + .with_arg("id", &edge.action_id), + })?; + + let requires_gates = + edge.dependency_order == DependencyOrder::Serial && edge.implicit_deps.len() > 1; + if requires_gates { + let mut added = Vec::new(); + render_serial_block( + edge, + &mut out, + &mut dyndep_files, + &mut staged_sidecars, + &mut added, + ) + .expect("write to String cannot fail"); + let mut aggregate = edge.clone(); + aggregate.implicit_deps = added; + aggregate.dependency_order = DependencyOrder::Parallel; + writeln!( + out, + "{}", + crate::ninja_gen::DisplayEdge { + edge: &aggregate, + action_restat: action.restat, + } + ) + .expect("write to String cannot fail"); + } else { + writeln!( + out, + "{}", + crate::ninja_gen::DisplayEdge { + edge, + action_restat: action.restat, + } + ) + .expect("write to String cannot fail"); + } + } + + if !graph.default_targets.is_empty() { + let mut defs = graph.default_targets.clone(); + defs.sort(); + writeln!(out, "default {}", join(&defs)).expect("write to String cannot fail"); + } + + Ok(GeneratedNinja { + build_file: out, + dyndep_files, + }) +} + +/// Emit the staged gates and sidecar-producing phony edges for one serial edge, +/// collecting each sidecar into the bundle and returning the gate paths in +/// dependency order. +fn render_serial_block( + edge: &BuildEdge, + out: &mut String, + dyndep_files: &mut Vec, + staged_sidecars: &mut HashSet, + gate_paths: &mut Vec, +) -> std::fmt::Result { + use crate::ninja_gen::escape_ninja_path; + + let parent = parent_identity(edge); + let mut previous_gate: Option = None; + for (index, dep) in edge.implicit_deps.iter().enumerate() { + let gate = parent.join(format!("{index:03}")); + let content = sidecar_content(&gate, dep); + let digest = sidecar_digest(&content); + let sidecar = Utf8PathBuf::from(format!("{DYNDEP_NAMESPACE}/{digest}.dd")); + + let sidecar_escaped = escape_ninja_path(sidecar.as_str()); + let gate_escaped = escape_ninja_path(gate.as_str()); + + // The phony edge that produces (but never rebuilds) the sidecar file. + // Starting at the second stage it depends on the previous gate, which + // prevents Ninja from revealing the next sidecar early. + match &previous_gate { + None => writeln!(out, "build {sidecar_escaped}: phony")?, + Some(prev) => { + let prev_escaped = escape_ninja_path(prev.as_str()); + writeln!(out, "build {sidecar_escaped}: phony {prev_escaped}")?; + } + } + // The gate edge: order-only depends on the sidecar and declares it as + // its dyndep file so Ninja loads the real dependency from the sidecar. + writeln!(out, "build {gate_escaped}: phony || {sidecar_escaped}")?; + writeln!(out, " dyndep = {sidecar_escaped}")?; + writeln!(out)?; + + if staged_sidecars.insert(sidecar.clone()) { + dyndep_files.push(GeneratedDyndep { + relative_path: sidecar, + content, + }); + } + gate_paths.push(gate.clone()); + previous_gate = Some(gate); + } + Ok(()) +} + +/// Derive the stable parent (gate-set) identity for a serial edge. +/// +/// The identity is a SHA-256 over the edge canonical output identity and an +/// explicit schema tag, so renaming the output or changing the staging format +/// yields a fresh namespace without colliding with other serial edges. +fn parent_identity(edge: &BuildEdge) -> Utf8PathBuf { + let canonical = edge + .explicit_outputs + .iter() + .map(|p| p.as_str()) + .collect::>() + .join("\u{0}"); + let mut hasher = Sha256::new(); + hasher.update(PARENT_SCHEMA.as_bytes()); + hasher.update(b"\0"); + hasher.update(canonical.as_bytes()); + let digest = hex::to_lower_hex(&hasher.finalize()); + Utf8PathBuf::from(format!("{SERIAL_NAMESPACE}/{digest}")) +} + +/// Render the dyndep document for one gate and its real dependency. +fn sidecar_content(gate: &Utf8PathBuf, dep: &Utf8PathBuf) -> String { + use crate::ninja_gen::escape_ninja_path; + let gate_escaped = escape_ninja_path(gate.as_str()); + let dep_escaped = escape_ninja_path(dep.as_str()); + format!("ninja_dyndep_version = 1\nbuild {gate_escaped}: dyndep | {dep_escaped}\n") +} + +/// Content-address a sidecar by its complete bytes and a format tag. +fn sidecar_digest(content: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(DYNDEP_SCHEMA.as_bytes()); + hasher.update(b"\0"); + hasher.update(content.as_bytes()); + hex::to_lower_hex(&hasher.finalize()) +} + +/// Reject user outputs or dependencies that collide with the reserved +/// serial-ordering state namespace. +fn reject_reserved_paths(graph: &BuildGraph) -> Result<(), NinjaGenError> { + for edge in graph.targets.values() { + for path in edge + .explicit_outputs + .iter() + .chain(&edge.implicit_outputs) + .chain(&edge.inputs) + .chain(&edge.implicit_deps) + .chain(&edge.order_only_deps) + { + let as_str = path.as_str(); + if as_str == SERIAL_NAMESPACE + || as_str == DYNDEP_NAMESPACE + || as_str.starts_with(&format!("{SERIAL_NAMESPACE}/")) + || as_str.starts_with(&format!("{DYNDEP_NAMESPACE}/")) + { + return Err(NinjaGenError::ReservedOutputPath { + path: path.clone(), + message: localization::message(keys::NINJA_GEN_RESERVED_OUTPUT_PATH) + .with_arg("path", as_str), + }); + } + } + } + Ok(()) +} + +/// Whether the graph contains an edge that needs staged dyndep gates. +fn graph_requires_dyndep(graph: &BuildGraph) -> bool { + graph.targets.values().any(|edge| { + edge.dependency_order == DependencyOrder::Serial && edge.implicit_deps.len() > 1 + }) +} + +#[cfg(test)] +#[path = "dyndep_tests.rs"] +mod tests; diff --git a/src/ninja_gen/dyndep_tests.rs b/src/ninja_gen/dyndep_tests.rs new file mode 100644 index 000000000..41821a92c --- /dev/null +++ b/src/ninja_gen/dyndep_tests.rs @@ -0,0 +1,185 @@ +//! Unit tests for staged dyndep bundle generation. + +use super::*; +use crate::ast::Recipe; +use crate::ir::{Action, BuildGraph}; +use anyhow::{Context, Result, ensure}; +use camino::Utf8PathBuf; + +fn action(command: &str) -> Action { + Action { + recipe: Recipe::Command { + command: command.into(), + }, + description: None, + depfile: None, + deps_format: None, + pool: None, + restat: false, + } +} + +fn serial_edge(output: &str, deps: &[&str]) -> BuildEdge { + let implicit_deps: Vec<_> = deps.iter().map(|d| Utf8PathBuf::from(d)).collect(); + BuildEdge { + action_id: "a".into(), + inputs: Vec::new(), + implicit_deps, + dependency_order: DependencyOrder::Serial, + explicit_outputs: vec![Utf8PathBuf::from(output)], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + } +} + +fn parallel_edge(output: &str, deps: &[&str]) -> BuildEdge { + let mut edge = serial_edge(output, deps); + edge.dependency_order = DependencyOrder::Parallel; + edge +} + +fn graph_with_edge(edge: BuildEdge) -> BuildGraph { + let mut graph = BuildGraph::default(); + graph.actions.insert("a".into(), action("echo done")); + graph.targets.insert(edge.explicit_outputs[0].clone(), edge); + graph +} + +#[test] +fn serial_bundle_emits_version_and_staged_sidecars() -> Result<()> { + let graph = graph_with_edge(serial_edge("all", &["check-fmt", "lint", "test"])); + let bundle = generate_bundle(&graph)?; + ensure!( + bundle + .build_file() + .contains("ninja_required_version = 1.10"), + "serial bundle must declare the Ninja version floor" + ); + ensure!( + bundle.dyndep_files().len() == 3, + "expected one sidecar per dependency, got {}", + bundle.dyndep_files().len() + ); + for dep in ["check-fmt", "lint", "test"] { + let revealed = bundle + .dyndep_files() + .iter() + .any(|dd| dd.content().contains(dep)); + ensure!(revealed, "expected a sidecar revealing {dep}"); + } + let file = bundle.build_file(); + ensure!( + file.lines() + .filter(|l| l.starts_with("build .netsuke/serial/")) + .count() + == 3, + "expected three gate edges" + ); + ensure!( + file.contains("build all: a |"), + "aggregate edge must list gates as implicit deps" + ); + Ok(()) +} + +#[test] +fn serial_sidecars_reveal_real_deps_in_order() -> Result<()> { + let graph = graph_with_edge(serial_edge("all", &["check-fmt", "lint", "test"])); + let bundle = generate_bundle(&graph)?; + let contents: Vec<&str> = bundle.dyndep_files().iter().map(|d| d.content()).collect(); + // Sidecar order follows declaration order because the first sidecar has no + // predecessor while later sidecars are produced by ordered edges. + let fmt_at = contents + .iter() + .position(|c| c.contains("check-fmt")) + .context("check-fmt sidecar missing")?; + let lint_at = contents + .iter() + .position(|c| c.contains("lint")) + .context("lint sidecar missing")?; + let test_at = contents + .iter() + .position(|c| c.contains("test")) + .context("test sidecar missing")?; + ensure!( + fmt_at < lint_at && lint_at < test_at, + "sidecars must preserve declaration order" + ); + Ok(()) +} + +#[test] +fn parallel_edges_produce_no_sidecars() -> Result<()> { + let graph = graph_with_edge(parallel_edge("all", &["dep1", "dep2"])); + let bundle = generate_bundle(&graph)?; + ensure!( + !bundle.build_file().contains("ninja_required_version"), + "parallel bundle must not emit a version floor" + ); + ensure!( + bundle.dyndep_files().is_empty(), + "parallel graph must produce no sidecars" + ); + Ok(()) +} + +#[test] +fn one_element_serial_list_needs_no_gates() -> Result<()> { + let graph = graph_with_edge(serial_edge("all", &["dep1"])); + let bundle = generate_bundle(&graph)?; + ensure!( + !bundle.build_file().contains("ninja_required_version"), + "single-dependency serial list must not emit a version floor" + ); + ensure!( + bundle.dyndep_files().is_empty(), + "single-dependency serial list must produce no sidecars" + ); + Ok(()) +} + +#[test] +fn repeated_dependency_keeps_separate_stage_sidecars() -> Result<()> { + let graph = graph_with_edge(serial_edge("all", &["same", "same"])); + let bundle = generate_bundle(&graph)?; + // Each gate stage is distinct, so each stage has its own content-addressed + // sidecar even when the revealed dependency is the same node. Ninja + // unifies the real dependency path, so its recipe still runs once. + ensure!( + bundle + .build_file() + .lines() + .filter(|l| l.starts_with("build .netsuke/serial/")) + .count() + == 2, + "expected two gate edges for a repeated dependency" + ); + ensure!( + bundle.dyndep_files().len() == 2, + "each stage needs its own sidecar, got {}", + bundle.dyndep_files().len() + ); + let contents: Vec<&str> = bundle.dyndep_files().iter().map(|d| d.content()).collect(); + ensure!( + contents.iter().all(|c| c.contains("same")), + "every stage sidecar must reveal the shared dependency" + ); + Ok(()) +} + +#[test] +fn reserved_output_namespace_is_rejected() -> Result<()> { + let mut edge = parallel_edge("all", &["dep"]); + edge.explicit_outputs = vec![Utf8PathBuf::from(".netsuke/serial/x")]; + let graph = graph_with_edge(edge); + let err = generate_bundle(&graph) + .err() + .context("reserved path must be rejected")?; + ensure!( + matches!(err, NinjaGenError::ReservedOutputPath { .. }), + "expected ReservedOutputPath, got {err:?}" + ); + Ok(()) +} diff --git a/src/ninja_gen.rs b/src/ninja_gen/mod.rs similarity index 87% rename from src/ninja_gen.rs rename to src/ninja_gen/mod.rs index 44367938f..e22b5d9b5 100644 --- a/src/ninja_gen.rs +++ b/src/ninja_gen/mod.rs @@ -6,6 +6,9 @@ //! generated Ninja file is written by the runner and `generate` command for //! downstream execution by the Ninja build system. +pub mod dyndep; +pub use dyndep::{GeneratedDyndep, GeneratedNinja, generate_bundle}; + use crate::ast::{Recipe, StringOrList}; use crate::ir::{BuildEdge, BuildGraph}; use crate::localization::{self, keys}; @@ -14,11 +17,11 @@ use itertools::Itertools; use std::collections::HashSet; use std::fmt::{self, Display, Formatter, Write}; -#[path = "ninja_gen_command_list.rs"] +#[path = "../ninja_gen_command_list.rs"] pub(crate) mod ninja_gen_command_list; -#[path = "ninja_gen_error.rs"] +#[path = "../ninja_gen_error.rs"] mod ninja_gen_error; -#[path = "ninja_gen_validation.rs"] +#[path = "../ninja_gen_validation.rs"] mod ninja_gen_validation; use ninja_gen_command_list::{ActionId, CommandListEntry, command_list_entry}; @@ -119,6 +122,11 @@ pub fn generate(graph: &BuildGraph) -> Result { /// structure, a command-list `eval` payload cannot be analysed, a command-list /// entry contains a Ninja control character, or writing to the output fails. pub fn generate_into(graph: &BuildGraph, out: &mut W) -> Result<(), NinjaGenError> { + if graph_requires_dyndep(graph) { + return Err(NinjaGenError::DyndepFilesRequired { + message: localization::message(keys::NINJA_GEN_DYNDEP_FILES_REQUIRED), + }); + } let mut actions: Vec<_> = graph.actions.iter().collect(); actions.sort_by_key(|(id, _)| *id); for (zero_based_action_index, (id, action)) in actions.into_iter().enumerate() { @@ -164,12 +172,12 @@ pub fn generate_into(graph: &BuildGraph, out: &mut W) -> Result<(), Ni } /// Convert a slice of paths into a space-separated string. -fn join(paths: &[Utf8PathBuf]) -> String { +pub(crate) fn join(paths: &[Utf8PathBuf]) -> String { paths.iter().map(|p| p.as_str()).join(" ") } /// Generate a stable key for a list of paths. -fn path_key(paths: &[Utf8PathBuf]) -> String { +pub(crate) fn path_key(paths: &[Utf8PathBuf]) -> String { let mut parts: Vec = paths.iter().map(|p| p.as_str().to_owned()).collect(); parts.sort_unstable(); let separator = char::from(0).to_string(); @@ -193,8 +201,34 @@ fn escape_script(script: &str) -> String { .replace('\n', "\\n") } +/// Escape a Ninja path for embedding in a build or dyndep document. +/// +/// Ninja uses `$` as its escape character: space becomes `$ `, a literal +/// dollar becomes `$$`, and the other token-splitting metacharacters (`:`, `|`) +/// gain a `$` prefix. Unescaped spaces split a single path into multiple +/// tokens, so every metacharacter must be escaped. +pub(crate) fn escape_ninja_path(path: &str) -> String { + let mut out = String::with_capacity(path.len()); + for ch in path.chars() { + match ch { + ' ' => out.push_str("$ "), + '$' => out.push_str("$$"), + ':' => out.push_str("$:"), + '|' => out.push_str("$|"), + _ => out.push(ch), + } + } + out +} + +/// Whether the graph contains an edge whose serial list needs dyndep gates. +pub(crate) fn graph_requires_dyndep(graph: &BuildGraph) -> bool { + graph.targets.values().any(|edge| { + edge.dependency_order == crate::ast::DependencyOrder::Serial && edge.implicit_deps.len() > 1 + }) +} /// Wrapper struct to display a rule with its identifier. -struct NamedAction<'a> { +pub(crate) struct NamedAction<'a> { id: &'a str, action: &'a crate::ir::Action, } @@ -303,7 +337,7 @@ impl Display for NamedAction<'_> { } /// Wrapper struct to display a build edge. -struct DisplayEdge<'a> { +pub(crate) struct DisplayEdge<'a> { edge: &'a BuildEdge, action_restat: bool, } @@ -330,11 +364,11 @@ impl Display for DisplayEdge<'_> { } } #[cfg(test)] -#[path = "ninja_gen_property_tests.rs"] +#[path = "../ninja_gen_property_tests.rs"] mod property_tests; #[cfg(test)] -#[path = "ninja_gen_test_support.rs"] +#[path = "../ninja_gen_test_support.rs"] mod test_support; #[cfg(test)] -#[path = "ninja_gen_tests.rs"] +#[path = "../ninja_gen_tests.rs"] mod tests; diff --git a/src/ninja_gen/tests.rs b/src/ninja_gen/tests.rs new file mode 100644 index 000000000..5b8a2ab9d --- /dev/null +++ b/src/ninja_gen/tests.rs @@ -0,0 +1,95 @@ +//! Unit tests for Ninja file generation and rule synthesis. +//! +//! Moved to its own file so the parent `mod.rs` stays under the repository's +//! 400-line ceiling. +//! Unit tests for Ninja file generation and rule synthesis. +use super::*; +use crate::ir::{Action, BuildEdge, BuildGraph}; +use anyhow::{Result, ensure}; +use rstest::rstest; +#[rstest] +fn generate_simple_ninja() -> Result<()> { + let action = Action { + recipe: Recipe::Command { + command: "echo hi".into(), + }, + description: None, + depfile: None, + deps_format: None, + pool: None, + restat: false, + }; + let edge = BuildEdge { + action_id: "a".into(), + inputs: vec![Utf8PathBuf::from("in")], + implicit_deps: Vec::new(), + dependency_order: crate::ast::DependencyOrder::Parallel, + explicit_outputs: vec![Utf8PathBuf::from("out")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + }; + let mut graph = BuildGraph::default(); + graph.actions.insert("a".into(), action); + graph.targets.insert(Utf8PathBuf::from("out"), edge); + graph.default_targets.push(Utf8PathBuf::from("out")); + + let ninja = generate(&graph)?; + let expected = concat!( + "rule a\n", + " command = echo hi\n\n", + "build out: a in\n\n", + "default out\n" + ); + ensure!( + ninja == expected, + "expected Ninja manifest:\n{expected}\nactual:\n{ninja}" + ); + Ok(()) +} + +#[rstest] +fn generate_script_ninja_round_trips() -> Result<()> { + let script = "echo 'a b' && echo \"$HOME\" && printf %s \"`whoami`\"\n# line"; + let action = Action { + recipe: Recipe::Script { + script: script.into(), + }, + description: None, + depfile: None, + deps_format: None, + pool: None, + restat: false, + }; + let edge = BuildEdge { + action_id: "a".into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: crate::ast::DependencyOrder::Parallel, + explicit_outputs: vec![Utf8PathBuf::from("out")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + }; + let mut graph = BuildGraph::default(); + graph.actions.insert("a".into(), action); + graph.targets.insert(Utf8PathBuf::from("out"), edge); + + let ninja = generate(&graph)?; + ensure!(ninja.contains("rule a")); + ensure!(ninja.contains("command = /bin/sh -e -c")); + ensure!(ninja.contains("echo '\"'\"'a b'\"'\"'")); + ensure!(ninja.contains("\\\"\\$HOME\\\"")); + ensure!(ninja.contains("\\`whoami\\`")); + ensure!(ninja.contains("printf %b")); + ensure!(ninja.contains("\\n# line' | /bin/sh -e")); + Ok(()) +} + +#[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_error.rs b/src/ninja_gen_error.rs index 714a195eb..f55281fe5 100644 --- a/src/ninja_gen_error.rs +++ b/src/ninja_gen_error.rs @@ -65,6 +65,21 @@ pub enum NinjaGenError { /// One-based stable position in the command list. entry_index: usize, }, + /// A graph with serial dependencies cannot be represented by a single + /// build-file string; callers must use [`crate::ninja_gen::generate_bundle`]. + #[error("{message}")] + DyndepFilesRequired { + /// Localized error message. + message: LocalizedMessage, + }, + /// A user graph path collides with Netsuke's reserved state namespace. + #[error("{message}")] + ReservedOutputPath { + /// Colliding path. + path: camino::Utf8PathBuf, + /// Localized error message. + message: LocalizedMessage, + }, /// Formatting the Ninja output failed. #[error("{message}")] Format { diff --git a/tests/ninja_gen_integration_tests.rs b/tests/ninja_gen_integration_tests.rs index dfb298347..ece6e9e99 100644 --- a/tests/ninja_gen_integration_tests.rs +++ b/tests/ninja_gen_integration_tests.rs @@ -467,3 +467,93 @@ fn generate_format_error() -> Result<()> { ); Ok(()) } + +#[rstest] +fn serial_graph_rejected_by_string_only_generation() -> Result<()> { + let action = Action { + recipe: Recipe::Command { + command: "echo done".into(), + }, + description: None, + depfile: None, + deps_format: None, + pool: None, + restat: false, + }; + let edge = BuildEdge { + action_id: "a".into(), + inputs: Vec::new(), + implicit_deps: vec![Utf8PathBuf::from("dep1"), Utf8PathBuf::from("dep2")], + dependency_order: netsuke::ast::DependencyOrder::Serial, + explicit_outputs: vec![Utf8PathBuf::from("all")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + }; + let mut graph = BuildGraph::default(); + graph.actions.insert("a".into(), action); + graph.targets.insert(Utf8PathBuf::from("all"), edge); + + let mut out = String::new(); + let err = generate_into(&graph, &mut out) + .err() + .context("serial graph must be rejected by string-only generation")?; + ensure!( + matches!(err, NinjaGenError::DyndepFilesRequired { .. }), + "expected DyndepFilesRequired, got {err:?}" + ); + ensure!( + out.is_empty(), + "string-only generation must not write partial output" + ); + Ok(()) +} + +#[rstest] +fn bundle_generation_for_serial_graph_materializes_sidecars() -> Result<()> { + let action = Action { + recipe: Recipe::Command { + command: "echo done".into(), + }, + description: None, + depfile: None, + deps_format: None, + pool: None, + restat: false, + }; + let edge = BuildEdge { + action_id: "a".into(), + inputs: Vec::new(), + implicit_deps: vec![Utf8PathBuf::from("check-fmt"), Utf8PathBuf::from("test")], + dependency_order: netsuke::ast::DependencyOrder::Serial, + explicit_outputs: vec![Utf8PathBuf::from("all")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + }; + let mut graph = BuildGraph::default(); + graph.actions.insert("a".into(), action); + graph.targets.insert(Utf8PathBuf::from("all"), edge); + + let bundle = netsuke::ninja_gen::generate_bundle(&graph)?; + ensure!( + bundle + .build_file() + .contains("ninja_required_version = 1.10"), + "serial bundle must declare version floor" + ); + ensure!( + bundle.dyndep_files().len() == 2, + "expected two sidecars, got {}", + bundle.dyndep_files().len() + ); + for dd in bundle.dyndep_files() { + ensure!( + dd.content().starts_with("ninja_dyndep_version = 1\n"), + "sidecar must start with dyndep version header" + ); + } + Ok(()) +} From 173a4152143bd83c4efd87a853196743cdc3892a Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 11 Aug 2026 00:16:43 +0200 Subject: [PATCH 05/69] Materialize dyndep sidecars in every runner path (#552) Add `src/runner/process/dyndep_files.rs`, which writes each generated sidecar beneath `.netsuke/dyndep` in the effective Ninja working directory through a capability-scoped handle: a same-directory `create_new` temporary file, flushed and synced, and an atomic rename. An existing file is verified and reused; mismatched bytes are reported as corruption and a concurrent writer that wins the race is treated as success only when the surviving content matches. Route `generate_ninja` through `generate_bundle` and materialize the bundle before every build, clean, and generate invocation writes or invokes the main Ninja file, so a serial manifest never reaches Ninja without its sidecars present. Add localization keys for the materializer errors across all 35 catalogues, with U+200F direction marks in the RTL locales. Add real-Ninja runtime tests proving strict declaration order and failure short-circuiting, plus materializer unit tests for creation, reuse, corruption detection, and temporary-file cleanup. The full suite (1930 tests) passes, and an end-to-end serial build over a real manifest observed `fmt, lint, test, all` order. --- ...ndency-ordering-for-actions-and-targets.md | 13 +- locales/ar/messages.ftl | 12 +- locales/cs/messages.ftl | 7 + locales/cy/messages.ftl | 7 + locales/da/messages.ftl | 7 + locales/de/messages.ftl | 7 + locales/el/messages.ftl | 7 + locales/en-GB/messages.ftl | 7 + locales/en-US/messages.ftl | 7 + locales/es-419/messages.ftl | 7 + locales/es-ES/messages.ftl | 7 + locales/fa/messages.ftl | 12 +- locales/fi/messages.ftl | 7 + locales/fr/messages.ftl | 7 + locales/gd/messages.ftl | 7 + locales/he/messages.ftl | 12 +- locales/hi/messages.ftl | 7 + locales/hu/messages.ftl | 7 + locales/id/messages.ftl | 7 + locales/it/messages.ftl | 7 + locales/ja/messages.ftl | 7 + locales/ko/messages.ftl | 7 + locales/nb/messages.ftl | 7 + locales/nl/messages.ftl | 7 + locales/pl/messages.ftl | 7 + locales/pt-BR/messages.ftl | 7 + locales/pt-PT/messages.ftl | 7 + locales/ro/messages.ftl | 7 + locales/ru/messages.ftl | 7 + locales/sv/messages.ftl | 7 + locales/th/messages.ftl | 7 + locales/tr/messages.ftl | 7 + locales/uk/messages.ftl | 7 + locales/vi/messages.ftl | 7 + locales/zh-Hans/messages.ftl | 7 + locales/zh-Hant/messages.ftl | 7 + src/localization/keys.rs | 6 + src/ninja_gen/dyndep.rs | 13 + src/runner/mod.rs | 5 +- src/runner/process/dyndep_files.rs | 258 ++++++++++++++++++ src/runner/process/mod.rs | 10 + tests/serial_dependency_runtime_tests.rs | 236 ++++++++++++++++ 42 files changed, 792 insertions(+), 9 deletions(-) create mode 100644 src/runner/process/dyndep_files.rs create mode 100644 tests/serial_dependency_runtime_tests.rs diff --git a/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md b/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md index 1b8f7f2cc..5ff9c9f3a 100644 --- a/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md +++ b/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md @@ -193,7 +193,18 @@ criterion in issue #552 and all repository gates pass. without writing partial output. Added reserved-namespace collision errors and localization keys across all 35 catalogues. Unit, integration, and doctests pass. -- [ ] Implement atomic dyndep sidecar materialization in every CLI path. +- [x] (2026-08-10) Implemented atomic dyndep sidecar materialization in + every CLI path. `src/runner/process/dyndep_files.rs` materializes the + bundle sidecars beneath `.netsuke/dyndep` relative to the effective Ninja + working directory, using capability-scoped writes, a same-directory + `create_new` temporary file, and an atomic rename. Existing content is + verified and reused; corruption and concurrent-writer outcomes are + covered. `generate_ninja` now routes every build, clean, and generate + invocation through `generate_bundle` plus materialization before invoking + Ninja. Added runtime tests driving real Ninja: strict declaration order, + failure short-circuiting, and materializer idempotence/corruption paths. + Verified end-to-end with a real serial manifest and real Ninja 1.11.1 + (order observed: fmt, lint, test, all). Full suite: 1930 tests pass. - [ ] Complete user, design, developer, layout, roadmap, and ADR documentation. - [ ] Run focused verification and all repository gates. - [ ] Commit each green logical change and record final evidence here. diff --git a/locales/ar/messages.ftl b/locales/ar/messages.ftl index 925b2a92a..4aa8e2584 100644 --- a/locales/ar/messages.ftl +++ b/locales/ar/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = تعذّر اشتقاق مسار Ninja النس runner.io.non_utf8_path = المسارات غير المرمّزة بـ UTF-8 غير مدعومة (المسار: { $path }). runner.io.write_stdout = تعذّرت كتابة ملف بيانات Ninja إلى المخرج القياسي. runner.io.flush_stdout = تعذّر إفراغ ذاكرة المخرج القياسي. +runner.io.dyndep.create_dir = ‏ Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = ‏ Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = ‏ Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = ‏ Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = ‏ Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = ‏ Another process wrote dyndep file { $path } but its content could not be verified. # تشخيصات ملف البيانات. manifest.parse = فشل تحليل ملف البيانات. @@ -172,8 +178,8 @@ ir.invalid_command = إقحام غير صالح داخل الأمر: { $snippet # أخطاء توليد ملفات Ninja. ninja_gen.missing_action = الإجراء «{ $id }» الذي تشير إليه حافة بناء مفقود. ninja_gen.format = تعذّر تنسيق مخرجات ملف بيانات Ninja. -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. +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. # التحقق من أنماط المضيفين. host_pattern.empty = يجب ألّا يكون نمط المضيف فارغًا. @@ -418,3 +424,5 @@ example.errors_found = { $count -> *[other] عُثر على { $count } خطأ. } + + diff --git a/locales/cs/messages.ftl b/locales/cs/messages.ftl index 77ac51e11..3dad15cfe 100644 --- a/locales/cs/messages.ftl +++ b/locales/cs/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = Relativní cestu pro Ninju se nepodařilo odvod runner.io.non_utf8_path = Cesty, které nejsou v UTF-8, nejsou podporovány (cesta: { $path }). runner.io.write_stdout = Manifest Ninja se nepodařilo zapsat na standardní výstup. runner.io.flush_stdout = Vyrovnávací paměť standardního výstupu se nepodařilo vyprázdnit. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Diagnostika manifestu. manifest.parse = Zpracování manifestu selhalo. @@ -415,3 +421,4 @@ example.errors_found = { $count -> *[other] Nalezeno { $count } chyb. } + diff --git a/locales/cy/messages.ftl b/locales/cy/messages.ftl index c71b799aa..cf455e8b0 100644 --- a/locales/cy/messages.ftl +++ b/locales/cy/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = Methwyd â deillio llwybr Ninja cymharol. runner.io.non_utf8_path = Ni chefnogir llwybrau nad ydynt yn UTF-8 (llwybr: { $path }). runner.io.write_stdout = Methwyd ag ysgrifennu'r maniffest Ninja i'r allbwn safonol. runner.io.flush_stdout = Methwyd â gwagio byffer yr allbwn safonol. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Diagnosteg y maniffest. manifest.parse = Methodd dadansoddiad y maniffest. @@ -418,3 +424,4 @@ example.errors_found = { $count -> *[other] Cafwyd hyd i { $count } gwall. } + diff --git a/locales/da/messages.ftl b/locales/da/messages.ftl index f03b15685..a1b6dd887 100644 --- a/locales/da/messages.ftl +++ b/locales/da/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = Den relative Ninja-sti kunne ikke udledes. runner.io.non_utf8_path = Stier, der ikke er UTF-8, understøttes ikke (sti: { $path }). runner.io.write_stdout = Ninja-manifestet kunne ikke skrives til stdout. runner.io.flush_stdout = Bufferen for stdout kunne ikke tømmes. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Manifestdiagnostik. manifest.parse = Parsingen af manifestet mislykkedes. @@ -410,3 +416,4 @@ example.errors_found = { $count -> *[other] { $count } fejl fundet. } + diff --git a/locales/de/messages.ftl b/locales/de/messages.ftl index 57281e672..7b56664c0 100644 --- a/locales/de/messages.ftl +++ b/locales/de/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = Der relative Ninja-Pfad konnte nicht abgeleitet runner.io.non_utf8_path = Pfade ohne gültiges UTF-8 werden nicht unterstützt (Pfad: { $path }). runner.io.write_stdout = Das Ninja-Manifest konnte nicht nach stdout geschrieben werden. runner.io.flush_stdout = stdout konnte nicht geleert werden. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Manifest-Diagnosen. manifest.parse = Das Parsen des Manifests ist fehlgeschlagen. @@ -410,3 +416,4 @@ example.errors_found = { $count -> *[other] { $count } Fehler gefunden. } + diff --git a/locales/el/messages.ftl b/locales/el/messages.ftl index 691a9b1c8..d513ef028 100644 --- a/locales/el/messages.ftl +++ b/locales/el/messages.ftl @@ -108,6 +108,12 @@ runner.io.derive_relative_path = Δεν ήταν δυνατή η εξαγωγή runner.io.non_utf8_path = Οι διαδρομές που δεν είναι UTF-8 δεν υποστηρίζονται (διαδρομή: { $path }). runner.io.write_stdout = Δεν ήταν δυνατή η εγγραφή του δηλωτικού Ninja στην τυπική έξοδο. runner.io.flush_stdout = Δεν ήταν δυνατή η εκκένωση της τυπικής εξόδου. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Διαγνωστικά δηλωτικού. manifest.parse = Η ανάλυση του δηλωτικού απέτυχε. @@ -412,3 +418,4 @@ example.errors_found = { $count -> *[other] Βρέθηκαν { $count } σφάλματα. } + diff --git a/locales/en-GB/messages.ftl b/locales/en-GB/messages.ftl index cb5e4ae20..07a170e1b 100644 --- a/locales/en-GB/messages.ftl +++ b/locales/en-GB/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = Failed to derive relative Ninja path. runner.io.non_utf8_path = Non-UTF-8 path is not supported (path: { $path }). runner.io.write_stdout = Failed to write Ninja manifest to stdout. runner.io.flush_stdout = Failed to flush stdout. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Manifest diagnostics. manifest.parse = Manifest parse failed. @@ -411,3 +417,4 @@ example.errors_found = { $count -> *[other] { $count } errors found. } + diff --git a/locales/en-US/messages.ftl b/locales/en-US/messages.ftl index 3d29ca723..1f2ac079b 100644 --- a/locales/en-US/messages.ftl +++ b/locales/en-US/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = Failed to derive relative Ninja path. runner.io.non_utf8_path = Non-UTF-8 path is not supported (path: { $path }). runner.io.write_stdout = Failed to write Ninja manifest to stdout. runner.io.flush_stdout = Failed to flush stdout. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Manifest diagnostics. manifest.parse = Manifest parse failed. @@ -415,3 +421,4 @@ example.errors_found = { $count -> *[other] { $count } errors found. } + diff --git a/locales/es-419/messages.ftl b/locales/es-419/messages.ftl index 550875dd2..3f247a47e 100644 --- a/locales/es-419/messages.ftl +++ b/locales/es-419/messages.ftl @@ -108,6 +108,12 @@ runner.io.derive_relative_path = No se pudo derivar la ruta relativa de Ninja. runner.io.non_utf8_path = No se admiten rutas que no sean UTF-8 (ruta: { $path }). runner.io.write_stdout = No se pudo escribir el manifiesto de Ninja en stdout. runner.io.flush_stdout = No se pudo vaciar stdout. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Diagnósticos del manifiesto. manifest.parse = Falló el análisis del manifiesto. @@ -413,3 +419,4 @@ example.errors_found = { $count -> *[other] Se encontraron { $count } errores. } + diff --git a/locales/es-ES/messages.ftl b/locales/es-ES/messages.ftl index 042c38a6d..e11a81b9e 100644 --- a/locales/es-ES/messages.ftl +++ b/locales/es-ES/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = No se pudo derivar la ruta relativa de Ninja. runner.io.non_utf8_path = No se admiten rutas no UTF-8 (ruta: { $path }). runner.io.write_stdout = No se pudo escribir el manifiesto Ninja en stdout. runner.io.flush_stdout = No se pudo vaciar stdout. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Diagnósticos del manifiesto. manifest.parse = Falló el análisis del manifiesto. @@ -414,3 +420,4 @@ example.errors_found = { $count -> *[other] Se encontraron { $count } errores. } + diff --git a/locales/fa/messages.ftl b/locales/fa/messages.ftl index 097377857..8becd7c55 100644 --- a/locales/fa/messages.ftl +++ b/locales/fa/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = استخراج مسیر نسبی Ninja ممکن runner.io.non_utf8_path = مسیرهایی که UTF-8 نیستند پشتیبانی نمی‌شوند (مسیر: { $path }). runner.io.write_stdout = نوشتن مانیفست Ninja در خروجی استاندارد ممکن نشد. runner.io.flush_stdout = تخلیهٔ میان‌گیر خروجی استاندارد ممکن نشد. +runner.io.dyndep.create_dir = ‏ Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = ‏ Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = ‏ Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = ‏ Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = ‏ Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = ‏ Another process wrote dyndep file { $path } but its content could not be verified. # تشخیص‌های مانیفست. manifest.parse = تجزیهٔ مانیفست ناکام ماند. @@ -172,8 +178,8 @@ ir.invalid_command = درج نامعتبر در فرمان: { $snippet }. # خطاهای تولید پرونده‌های Ninja. ninja_gen.missing_action = کنش «{ $id }» که یک یال ساخت به آن ارجاع می‌دهد وجود ندارد. ninja_gen.format = قالب‌بندی خروجی مانیفست Ninja ممکن نشد. -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. +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. # اعتبارسنجی الگوهای میزبان. host_pattern.empty = الگوی میزبان نباید تهی باشد. @@ -410,3 +416,5 @@ example.errors_found = { $count -> *[other] ‏{ $count } خطا یافت شد. } + + diff --git a/locales/fi/messages.ftl b/locales/fi/messages.ftl index 962be1085..0153b9ebc 100644 --- a/locales/fi/messages.ftl +++ b/locales/fi/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = Suhteellista Ninja-polkua ei voitu johtaa. runner.io.non_utf8_path = Polkuja, jotka eivät ole UTF-8:aa, ei tueta (polku: { $path }). runner.io.write_stdout = Ninja-manifestia ei voitu kirjoittaa vakiotulosteeseen. runner.io.flush_stdout = Vakiotulosteen puskuria ei voitu tyhjentää. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Manifestin diagnostiikka. manifest.parse = Manifestin jäsentäminen epäonnistui. @@ -412,3 +418,4 @@ example.errors_found = { $count -> *[other] Löytyi { $count } virhettä. } + diff --git a/locales/fr/messages.ftl b/locales/fr/messages.ftl index 6e66c5866..cc7f7ae1d 100644 --- a/locales/fr/messages.ftl +++ b/locales/fr/messages.ftl @@ -108,6 +108,12 @@ runner.io.derive_relative_path = Impossible de déduire le chemin Ninja relatif. runner.io.non_utf8_path = Les chemins non UTF-8 ne sont pas pris en charge (chemin : { $path }). runner.io.write_stdout = Impossible d'écrire le manifeste Ninja sur la sortie standard. runner.io.flush_stdout = Impossible de vider le tampon de la sortie standard. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Diagnostics du manifeste. manifest.parse = L'analyse du manifeste a échoué. @@ -412,3 +418,4 @@ example.errors_found = { $count -> *[other] { $count } erreurs trouvées. } + diff --git a/locales/gd/messages.ftl b/locales/gd/messages.ftl index d27af2199..07073d717 100644 --- a/locales/gd/messages.ftl +++ b/locales/gd/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = Cha b' urrainnear slighe Ninja choimeasach a th runner.io.non_utf8_path = Chan eil taic ann do shlighean nach eil nan UTF-8 (slighe: { $path }). runner.io.write_stdout = Cha b' urrainnear am foirm-liosta Ninja a sgrìobhadh don às-chur àbhaisteach. runner.io.flush_stdout = Cha b' urrainnear bufair an às-chuir àbhaistich fhalmhachadh. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Breithneachadh an fhoirm-liosta. manifest.parse = Dh'fhàillig parsadh an fhoirm-liosta. @@ -415,3 +421,4 @@ example.errors_found = { $count -> *[other] Chaidh { $count } mearachd a lorg. } + diff --git a/locales/he/messages.ftl b/locales/he/messages.ftl index d174ae7bb..74e606259 100644 --- a/locales/he/messages.ftl +++ b/locales/he/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = לא ניתן היה לגזור את נתיב N runner.io.non_utf8_path = נתיבים שאינם UTF-8 אינם נתמכים (נתיב: { $path }). runner.io.write_stdout = לא ניתן היה לכתוב את מניפסט Ninja לפלט התקני. runner.io.flush_stdout = לא ניתן היה לרוקן את החוצץ של הפלט התקני. +runner.io.dyndep.create_dir = ‏ Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = ‏ Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = ‏ Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = ‏ Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = ‏ Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = ‏ Another process wrote dyndep file { $path } but its content could not be verified. # אבחון המניפסט. manifest.parse = ניתוח המניפסט נכשל. @@ -172,8 +178,8 @@ ir.invalid_command = שיבוץ לא תקין בפקודה: { $snippet }. # שגיאות ביצירת קובצי Ninja. ninja_gen.missing_action = הפעולה „{ $id }” שאליה מפנה קשת בנייה חסרה. ninja_gen.format = לא ניתן היה לעצב את פלט מניפסט Ninja. -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. +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. # אימות תבניות מארח. host_pattern.empty = תבנית המארח אינה יכולה להיות ריקה. @@ -415,3 +421,5 @@ example.errors_found = { $count -> *[other] נמצאו { $count } שגיאות. } + + diff --git a/locales/hi/messages.ftl b/locales/hi/messages.ftl index e00e8d5ad..ded2422d5 100644 --- a/locales/hi/messages.ftl +++ b/locales/hi/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = सापेक्ष Ninja पथ नहीं runner.io.non_utf8_path = UTF-8 से भिन्न पथ समर्थित नहीं हैं (पथ: { $path })। runner.io.write_stdout = Ninja मैनिफ़ेस्ट मानक निर्गम पर नहीं लिखा जा सका। runner.io.flush_stdout = मानक निर्गम का बफ़र खाली नहीं किया जा सका। +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # मैनिफ़ेस्ट के निदान। manifest.parse = मैनिफ़ेस्ट का विश्लेषण विफल रहा। @@ -413,3 +419,4 @@ example.errors_found = { $count -> *[other] { $count } त्रुटियाँ मिलीं। } + diff --git a/locales/hu/messages.ftl b/locales/hu/messages.ftl index 347641df4..9582c324b 100644 --- a/locales/hu/messages.ftl +++ b/locales/hu/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = A viszonylagos Ninja-útvonalat nem sikerült l runner.io.non_utf8_path = A nem UTF-8 útvonalak nem támogatottak (útvonal: { $path }). runner.io.write_stdout = A Ninja-jegyzéket nem sikerült a szabványos kimenetre írni. runner.io.flush_stdout = A szabványos kimenet pufferét nem sikerült üríteni. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Jegyzékdiagnosztika. manifest.parse = A jegyzék feldolgozása sikertelen. @@ -412,3 +418,4 @@ example.errors_found = { $count -> *[other] { $count } hiba található. } + diff --git a/locales/id/messages.ftl b/locales/id/messages.ftl index 2d09f91e5..72cb3ea0e 100644 --- a/locales/id/messages.ftl +++ b/locales/id/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = Jalur Ninja relatif tidak dapat diturunkan. runner.io.non_utf8_path = Jalur yang bukan UTF-8 tidak didukung (jalur: { $path }). runner.io.write_stdout = Manifes Ninja tidak dapat ditulis ke keluaran standar. runner.io.flush_stdout = Penyangga keluaran standar tidak dapat dikosongkan. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Diagnostik manifes. manifest.parse = Penguraian manifes gagal. @@ -409,3 +415,4 @@ example.errors_found = { $count -> *[other] { $count } galat ditemukan. } + diff --git a/locales/it/messages.ftl b/locales/it/messages.ftl index f963973cf..22a7104b2 100644 --- a/locales/it/messages.ftl +++ b/locales/it/messages.ftl @@ -108,6 +108,12 @@ runner.io.derive_relative_path = Impossibile derivare il percorso Ninja relativo runner.io.non_utf8_path = I percorsi non UTF-8 non sono supportati (percorso: { $path }). runner.io.write_stdout = Impossibile scrivere il manifest Ninja su stdout. runner.io.flush_stdout = Impossibile svuotare il buffer di stdout. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Diagnostica del manifest. manifest.parse = Analisi del manifest non riuscita. @@ -411,3 +417,4 @@ example.errors_found = { $count -> *[other] Trovati { $count } errori. } + diff --git a/locales/ja/messages.ftl b/locales/ja/messages.ftl index 37da8d008..e9686dbde 100644 --- a/locales/ja/messages.ftl +++ b/locales/ja/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = Ninja の相対パスを導出できません runner.io.non_utf8_path = UTF-8 でないパスには対応していません(パス: { $path })。 runner.io.write_stdout = Ninja マニフェストを標準出力に書き込めませんでした。 runner.io.flush_stdout = 標準出力のバッファーを書き出せませんでした。 +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # マニフェストの診断。 manifest.parse = マニフェストの解析に失敗しました。 @@ -408,3 +414,4 @@ example.errors_found = { $count -> *[other] { $count } 件のエラーが見つかりました。 } + diff --git a/locales/ko/messages.ftl b/locales/ko/messages.ftl index 03c6d2d74..76257af8f 100644 --- a/locales/ko/messages.ftl +++ b/locales/ko/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = 상대 Ninja 경로를 유도하지 못했습 runner.io.non_utf8_path = UTF-8이 아닌 경로는 지원하지 않습니다(경로: { $path }). runner.io.write_stdout = Ninja 매니페스트를 표준 출력에 쓰지 못했습니다. runner.io.flush_stdout = 표준 출력의 버퍼를 비우지 못했습니다. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # 매니페스트 진단. manifest.parse = 매니페스트 해석에 실패했습니다. @@ -408,3 +414,4 @@ example.errors_found = { $count -> *[other] 오류 { $count }개를 찾았습니다. } + diff --git a/locales/nb/messages.ftl b/locales/nb/messages.ftl index 747996dec..ceffc7b1a 100644 --- a/locales/nb/messages.ftl +++ b/locales/nb/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = Den relative Ninja-stien kunne ikke utledes. runner.io.non_utf8_path = Stier som ikke er UTF-8, støttes ikke (sti: { $path }). runner.io.write_stdout = Ninja-manifestet kunne ikke skrives til stdout. runner.io.flush_stdout = Bufferen for stdout kunne ikke tømmes. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Manifestdiagnostikk. manifest.parse = Innlesingen av manifestet mislyktes. @@ -410,3 +416,4 @@ example.errors_found = { $count -> *[other] { $count } feil funnet. } + diff --git a/locales/nl/messages.ftl b/locales/nl/messages.ftl index 22f32b489..78f90a37e 100644 --- a/locales/nl/messages.ftl +++ b/locales/nl/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = Het relatieve Ninja-pad kon niet worden afgelei runner.io.non_utf8_path = Paden die geen UTF-8 zijn, worden niet ondersteund (pad: { $path }). runner.io.write_stdout = Het Ninja-manifest kon niet naar stdout worden geschreven. runner.io.flush_stdout = De buffer van stdout kon niet worden geleegd. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Manifestdiagnostiek. manifest.parse = Het inlezen van het manifest is mislukt. @@ -411,3 +417,4 @@ example.errors_found = { $count -> *[other] { $count } fouten gevonden. } + diff --git a/locales/pl/messages.ftl b/locales/pl/messages.ftl index b7b538c60..baff1290a 100644 --- a/locales/pl/messages.ftl +++ b/locales/pl/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = Nie udało się wyznaczyć względnej ścieżki runner.io.non_utf8_path = Ścieżki inne niż UTF-8 nie są obsługiwane (ścieżka: { $path }). runner.io.write_stdout = Nie udało się zapisać manifestu Ninja na standardowe wyjście. runner.io.flush_stdout = Nie udało się opróżnić bufora standardowego wyjścia. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Diagnostyka manifestu. manifest.parse = Analiza manifestu nie powiodła się. @@ -416,3 +422,4 @@ example.errors_found = { $count -> *[other] Znaleziono { $count } błędu. } + diff --git a/locales/pt-BR/messages.ftl b/locales/pt-BR/messages.ftl index 804bc2aec..8d2a7b1ca 100644 --- a/locales/pt-BR/messages.ftl +++ b/locales/pt-BR/messages.ftl @@ -108,6 +108,12 @@ runner.io.derive_relative_path = Não foi possível derivar o caminho relativo d runner.io.non_utf8_path = Não há suporte para caminhos que não sejam UTF-8 (caminho: { $path }). runner.io.write_stdout = Não foi possível gravar o manifesto do Ninja na stdout. runner.io.flush_stdout = Não foi possível esvaziar o buffer da stdout. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Diagnósticos do manifesto. manifest.parse = A análise do manifesto falhou. @@ -412,3 +418,4 @@ example.errors_found = { $count -> *[other] { $count } erros encontrados. } + diff --git a/locales/pt-PT/messages.ftl b/locales/pt-PT/messages.ftl index 39aee75c6..36fc6bdc3 100644 --- a/locales/pt-PT/messages.ftl +++ b/locales/pt-PT/messages.ftl @@ -108,6 +108,12 @@ runner.io.derive_relative_path = Não foi possível derivar o caminho Ninja rela runner.io.non_utf8_path = Não são suportados caminhos que não sejam UTF-8 (caminho: { $path }). runner.io.write_stdout = Não foi possível escrever o manifesto Ninja no stdout. runner.io.flush_stdout = Não foi possível esvaziar o buffer do stdout. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Diagnósticos do manifesto. manifest.parse = A análise do manifesto falhou. @@ -412,3 +418,4 @@ example.errors_found = { $count -> *[other] Foram encontrados { $count } erros. } + diff --git a/locales/ro/messages.ftl b/locales/ro/messages.ftl index 5dc343ca6..db8bc4eb5 100644 --- a/locales/ro/messages.ftl +++ b/locales/ro/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = Calea Ninja relativă nu a putut fi dedusă. runner.io.non_utf8_path = Căile care nu sunt UTF-8 nu sunt acceptate (calea: { $path }). runner.io.write_stdout = Manifestul Ninja nu a putut fi scris la ieșirea standard. runner.io.flush_stdout = Memoria tampon a ieșirii standard nu a putut fi golită. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Diagnostice ale manifestului. manifest.parse = Analiza manifestului a eșuat. @@ -414,3 +420,4 @@ example.errors_found = { $count -> *[other] S-au găsit { $count } de erori. } + diff --git a/locales/ru/messages.ftl b/locales/ru/messages.ftl index 7f5c6c12d..dce8c8df7 100644 --- a/locales/ru/messages.ftl +++ b/locales/ru/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = Не удалось вывести относи runner.io.non_utf8_path = Пути, отличные от UTF-8, не поддерживаются (путь: { $path }). runner.io.write_stdout = Не удалось записать манифест Ninja в стандартный поток вывода. runner.io.flush_stdout = Не удалось сбросить буфер стандартного потока вывода. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Диагностика манифеста. manifest.parse = Не удалось разобрать манифест. @@ -417,3 +423,4 @@ example.errors_found = { $count -> *[other] Найдено { $count } ошибки. } + diff --git a/locales/sv/messages.ftl b/locales/sv/messages.ftl index 354387ec0..fbf096a8b 100644 --- a/locales/sv/messages.ftl +++ b/locales/sv/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = Den relativa Ninja-sökvägen kunde inte härle runner.io.non_utf8_path = Sökvägar som inte är UTF-8 stöds inte (sökväg: { $path }). runner.io.write_stdout = Ninja-manifestet kunde inte skrivas till stdout. runner.io.flush_stdout = Bufferten för stdout kunde inte tömmas. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Manifestdiagnostik. manifest.parse = Tolkningen av manifestet misslyckades. @@ -410,3 +416,4 @@ example.errors_found = { $count -> *[other] { $count } fel hittades. } + diff --git a/locales/th/messages.ftl b/locales/th/messages.ftl index e798b23b6..f5c0003d5 100644 --- a/locales/th/messages.ftl +++ b/locales/th/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = อนุมานเส้นทางสั runner.io.non_utf8_path = ไม่รองรับเส้นทางที่ไม่ใช่ UTF-8 (เส้นทาง: { $path }) runner.io.write_stdout = เขียนไฟล์รายการ Ninja ไปยังเอาต์พุตมาตรฐานไม่สำเร็จ runner.io.flush_stdout = ล้างบัฟเฟอร์ของเอาต์พุตมาตรฐานไม่สำเร็จ +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # การวินิจฉัยไฟล์รายการ manifest.parse = การแจงไฟล์รายการล้มเหลว @@ -408,3 +414,4 @@ example.errors_found = { $count -> *[other] พบข้อผิดพลาด { $count } รายการ } + diff --git a/locales/tr/messages.ftl b/locales/tr/messages.ftl index 3bd2c29c7..905bef0b3 100644 --- a/locales/tr/messages.ftl +++ b/locales/tr/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = Göreli Ninja yolu türetilemedi. runner.io.non_utf8_path = UTF-8 olmayan yollar desteklenmiyor (yol: { $path }). runner.io.write_stdout = Ninja bildirimi standart çıktıya yazılamadı. runner.io.flush_stdout = Standart çıktının arabelleği boşaltılamadı. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Bildirim tanılaması. manifest.parse = Bildirimin ayrıştırılması başarısız oldu. @@ -411,3 +417,4 @@ example.errors_found = { $count -> *[other] { $count } hata bulundu. } + diff --git a/locales/uk/messages.ftl b/locales/uk/messages.ftl index 4892c049c..da38e5d6f 100644 --- a/locales/uk/messages.ftl +++ b/locales/uk/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = Не вдалося вивести віднос runner.io.non_utf8_path = Шляхи, відмінні від UTF-8, не підтримуються (шлях: { $path }). runner.io.write_stdout = Не вдалося записати маніфест Ninja у стандартний потік виводу. runner.io.flush_stdout = Не вдалося скинути буфер стандартного потоку виводу. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Діагностика маніфесту. manifest.parse = Не вдалося розібрати маніфест. @@ -417,3 +423,4 @@ example.errors_found = { $count -> *[other] Знайдено { $count } помилки. } + diff --git a/locales/vi/messages.ftl b/locales/vi/messages.ftl index bddca3b49..cd7608d50 100644 --- a/locales/vi/messages.ftl +++ b/locales/vi/messages.ftl @@ -107,6 +107,12 @@ runner.io.derive_relative_path = Không suy ra được đường dẫn Ninja t runner.io.non_utf8_path = Không hỗ trợ đường dẫn không phải UTF-8 (đường dẫn: { $path }). runner.io.write_stdout = Không ghi được tệp kê khai Ninja ra đầu ra chuẩn. runner.io.flush_stdout = Không xả được bộ đệm đầu ra chuẩn. +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # Chẩn đoán tệp kê khai. manifest.parse = Phân tích tệp kê khai thất bại. @@ -408,3 +414,4 @@ example.errors_found = { $count -> *[other] Tìm thấy { $count } lỗi. } + diff --git a/locales/zh-Hans/messages.ftl b/locales/zh-Hans/messages.ftl index 9ef8cabc9..71ed4614c 100644 --- a/locales/zh-Hans/messages.ftl +++ b/locales/zh-Hans/messages.ftl @@ -106,6 +106,12 @@ runner.io.derive_relative_path = 无法推导 Ninja 的相对路径。 runner.io.non_utf8_path = 不支持非 UTF-8 路径(路径:{ $path })。 runner.io.write_stdout = 无法将 Ninja 清单写入标准输出。 runner.io.flush_stdout = 无法刷新标准输出的缓冲区。 +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # 清单诊断。 manifest.parse = 清单解析失败。 @@ -407,3 +413,4 @@ example.errors_found = { $count -> *[other] 发现 { $count } 个错误。 } + diff --git a/locales/zh-Hant/messages.ftl b/locales/zh-Hant/messages.ftl index 51df86f05..0c4fef817 100644 --- a/locales/zh-Hant/messages.ftl +++ b/locales/zh-Hant/messages.ftl @@ -106,6 +106,12 @@ runner.io.derive_relative_path = 無法推導 Ninja 的相對路徑。 runner.io.non_utf8_path = 不支援非 UTF-8 的路徑(路徑:{ $path })。 runner.io.write_stdout = 無法將 Ninja 資訊清單寫入標準輸出。 runner.io.flush_stdout = 無法清空標準輸出的緩衝區。 +runner.io.dyndep.create_dir = Failed to create the dyndep directory { $path }. +runner.io.dyndep.read = Failed to read generated dyndep file at { $path }. +runner.io.dyndep.write = Failed to write generated dyndep file at { $path }. +runner.io.dyndep.rename = Failed to finalize generated dyndep file at { $path }. +runner.io.dyndep.corrupt = Generated dyndep file at { $path } does not match its expected content; remove that single file and retry. +runner.io.dyndep.race = Another process wrote dyndep file { $path } but its content could not be verified. # 資訊清單診斷。 manifest.parse = 資訊清單剖析失敗。 @@ -407,3 +413,4 @@ example.errors_found = { $count -> *[other] 發現 { $count } 個錯誤。 } + diff --git a/src/localization/keys.rs b/src/localization/keys.rs index f0fc9a777..b23932aed 100644 --- a/src/localization/keys.rs +++ b/src/localization/keys.rs @@ -94,6 +94,12 @@ define_keys! { RUNNER_IO_NON_UTF8_PATH => "runner.io.non_utf8_path", RUNNER_IO_WRITE_STDOUT => "runner.io.write_stdout", RUNNER_IO_FLUSH_STDOUT => "runner.io.flush_stdout", + RUNNER_IO_DYNDEP_CREATE_DIR => "runner.io.dyndep.create_dir", + RUNNER_IO_DYNDEP_READ => "runner.io.dyndep.read", + RUNNER_IO_DYNDEP_WRITE => "runner.io.dyndep.write", + RUNNER_IO_DYNDEP_RENAME => "runner.io.dyndep.rename", + RUNNER_IO_DYNDEP_CORRUPT => "runner.io.dyndep.corrupt", + RUNNER_IO_DYNDEP_RACE => "runner.io.dyndep.race", MANIFEST_PARSE => "manifest.parse", MANIFEST_STRUCTURE_ERROR => "manifest.structure_error", MANIFEST_YAML_PARSE => "manifest.yaml.parse", diff --git a/src/ninja_gen/dyndep.rs b/src/ninja_gen/dyndep.rs index cf3678065..bcc165fd6 100644 --- a/src/ninja_gen/dyndep.rs +++ b/src/ninja_gen/dyndep.rs @@ -128,6 +128,19 @@ impl GeneratedNinja { } } +#[cfg(test)] +impl GeneratedDyndep { + /// Build a sidecar fixture for tests that must construct bundles from + /// scratch rather than through . + #[must_use] + pub(crate) fn fixture(relative_path: Utf8PathBuf, content: String) -> Self { + Self { + relative_path, + content, + } + } +} + /// Generate a complete Ninja bundle for `graph`, materializing staged dyndep /// sidecars for every multi-dependency serial edge. /// diff --git a/src/runner/mod.rs b/src/runner/mod.rs index 967d831c3..9a434266c 100644 --- a/src/runner/mod.rs +++ b/src/runner/mod.rs @@ -361,9 +361,10 @@ fn generate_ninja( PipelineStage::NinjaSynthesisAndExecution, tool_key, ); - let ninja = ninja_gen::generate(&graph) + let bundle = ninja_gen::generate_bundle(&graph) .context(localization::message(keys::RUNNER_CONTEXT_GENERATE_NINJA))?; - Ok(NinjaContent::new(ninja)) + process::materialize_dyndep_files(cli, bundle.dyndep_files())?; + Ok(NinjaContent::new(bundle.build_file().to_owned())) } pub(super) fn load_manifest_with_stage_reporting( diff --git a/src/runner/process/dyndep_files.rs b/src/runner/process/dyndep_files.rs new file mode 100644 index 000000000..84d157892 --- /dev/null +++ b/src/runner/process/dyndep_files.rs @@ -0,0 +1,258 @@ +//! Atomic materialization of generated Ninja dyndep sidecars. +//! +//! Serial-dependency manifests reference dyndep sidecars beneath +//! `.netsuke/dyndep` in the effective Ninja working directory. Ninja requires +//! those files to exist before it loads a serial build file, so every runner +//! path materializes them before writing or invoking the main file. Sidecar +//! filenames are content-addressed, so writes are deterministic and idempotent: +//! an existing file whose bytes match is reused, while a mismatch is treated as +//! corruption. +//! +//! All writes go through a capability-scoped directory handle opened on the +//! effective Ninja working directory, using a same-directory temporary file and +//! an atomic rename so concurrent Netsuke processes cannot observe partial +//! content. + +use crate::cli::Cli; +use crate::localization::{self, keys}; +use crate::ninja_gen::GeneratedDyndep; +use anyhow::{Context, Result, anyhow}; +use camino::{Utf8Path, Utf8PathBuf}; +use cap_std::ambient_authority; +use cap_std::fs_utf8::{Dir, OpenOptions}; +use std::io::{Read, Write}; + +/// Namespace for generated dyndep sidecar files. +pub(crate) const DYNDEP_DIR: &str = ".netsuke/dyndep"; + +/// Materialize every sidecar in `dyndep_files` under the effective Ninja +/// working directory selected by `cli`. +/// +/// # Errors +/// +/// Returns an error if the working directory cannot be opened or the +/// `.netsuke/dyndep` directory created, or if any sidecar write, rename, or +/// content verification fails. +pub fn materialize_dyndep_files(cli: &Cli, dyndep_files: &[GeneratedDyndep]) -> Result<()> { + let dir = open_effective_dir(cli)?; + dir.create_dir_all(DYNDEP_DIR).with_context(|| { + localization::message(keys::RUNNER_IO_DYNDEP_CREATE_DIR) + .with_arg("path", DYNDEP_DIR.to_string()) + })?; + for sidecar in dyndep_files { + materialize_one(&dir, sidecar)?; + } + Ok(()) +} + +/// Open the effective Ninja working directory through the capability seam. +/// +/// Honours the CLI `--directory` option; otherwise uses the current directory. +fn open_effective_dir(cli: &Cli) -> Result { + if let Some(dir) = &cli.directory { + let utf8 = Utf8Path::from_path(dir).context("non-UTF-8 working directory")?; + Dir::open_ambient_dir(utf8.as_str(), ambient_authority()).with_context(|| { + localization::message(keys::RUNNER_IO_OPEN_AMBIENT_DIR).with_arg("path", utf8.as_str()) + }) + } else { + Dir::open_ambient_dir(".", ambient_authority()) + .context(localization::message(keys::RUNNER_IO_OPEN_AMBIENT_DIR)) + } +} + +/// Materialize one sidecar idempotently and atomically. +fn materialize_one(dir: &Dir, sidecar: &GeneratedDyndep) -> Result<()> { + let rel = sidecar.relative_path().clone(); + match read_verified(dir, &rel, sidecar.content())? { + ReadOutcome::Matching => { + tracing::debug!( + path = %rel, + "reusing existing dyndep sidecar", + ); + Ok(()) + } + ReadOutcome::Mismatch => Err(anyhow!( + localization::message(keys::RUNNER_IO_DYNDEP_CORRUPT).with_arg("path", rel.as_str()) + )), + ReadOutcome::Missing => write_atomic(dir, &rel, sidecar.content()), + } +} + +#[derive(PartialEq)] +enum ReadOutcome { + Matching, + Mismatch, + Missing, +} + +/// Read an existing sidecar and compare it with the expected content. +fn read_verified(dir: &Dir, rel: &Utf8Path, expected: &str) -> Result { + let mut options = OpenOptions::new(); + options.read(true); + let mut file = match dir.open_with(rel, &options) { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(ReadOutcome::Missing), + Err(err) => { + return Err(err).with_context(|| { + localization::message(keys::RUNNER_IO_DYNDEP_READ).with_arg("path", rel.as_str()) + }); + } + }; + let mut buf = Vec::new(); + file.read_to_end(&mut buf).with_context(|| { + localization::message(keys::RUNNER_IO_DYNDEP_READ).with_arg("path", rel.as_str()) + })?; + if buf == expected.as_bytes() { + Ok(ReadOutcome::Matching) + } else { + Ok(ReadOutcome::Mismatch) + } +} + +/// Write a sidecar via a unique same-directory temporary file and an atomic +/// rename, tolerating a concurrent writer that wins the race. +fn write_atomic(dir: &Dir, rel: &Utf8Path, content: &str) -> Result<()> { + let temp = unique_temp_name(rel); + let mut options = OpenOptions::new(); + options.write(true).create_new(true); + let mut file = match dir.open_with(&temp, &options) { + Ok(file) => file, + Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => { + // Another process won the race for our temporary name; verify the + // final path and treat matching content as success. + return match read_verified(dir, rel, content)? { + ReadOutcome::Matching => Ok(()), + ReadOutcome::Mismatch => Err(anyhow!( + localization::message(keys::RUNNER_IO_DYNDEP_CORRUPT) + .with_arg("path", rel.as_str()) + )), + ReadOutcome::Missing => Err(anyhow!( + localization::message(keys::RUNNER_IO_DYNDEP_RACE) + .with_arg("path", rel.as_str()) + )), + }; + } + Err(err) => { + return Err(err).with_context(|| { + localization::message(keys::RUNNER_IO_DYNDEP_WRITE).with_arg("path", rel.as_str()) + }); + } + }; + file.write_all(content.as_bytes()).with_context(|| { + localization::message(keys::RUNNER_IO_DYNDEP_WRITE).with_arg("path", rel.as_str()) + })?; + file.flush().with_context(|| { + localization::message(keys::RUNNER_IO_DYNDEP_WRITE).with_arg("path", rel.as_str()) + })?; + file.sync_all().with_context(|| { + localization::message(keys::RUNNER_IO_DYNDEP_WRITE).with_arg("path", rel.as_str()) + })?; + // Rename is relative to the same directory; `rename` replaces an existing + // destination, so if another process already wrote the final file, the + // atomic replace yields content identical to ours. + if let Err(err) = dir.rename(&temp, dir, rel) { + // The final file may have appeared via a concurrent writer; verify it. + if read_verified(dir, rel, content)? != ReadOutcome::Matching { + return Err(err).with_context(|| { + localization::message(keys::RUNNER_IO_DYNDEP_RENAME).with_arg("path", rel.as_str()) + }); + } + let _ = dir.remove_file(&temp); + } + Ok(()) +} + +/// Produce a deterministic, low-collision temporary name beside the final path. +/// +/// Uses the sidecar digest plus a fixed suffix; `create_new` guarantees the +/// write never truncates an existing file, and a collision falls back to +/// re-verification of the final content. +fn unique_temp_name(rel: &Utf8Path) -> Utf8PathBuf { + let name = rel.file_name().unwrap_or("sidecar.dd"); + rel.parent() + .map(|parent| parent.join(format!("{name}.tmp"))) + .unwrap_or_else(|| Utf8PathBuf::from(format!("{name}.tmp"))) +} + +#[cfg(test)] +mod tests { + //! Unit tests for atomic dyndep sidecar materialization. + + use super::*; + use crate::ninja_gen::GeneratedDyndep; + use anyhow::{Result, ensure}; + use camino::Utf8PathBuf; + use std::fs; + + fn temp_cli(dir: &std::path::Path) -> Cli { + Cli { + directory: Some(dir.to_path_buf()), + ..Cli::default() + } + } + + fn sidecar(name: &str, content: &str) -> GeneratedDyndep { + GeneratedDyndep::fixture(Utf8PathBuf::from(name), content.to_owned()) + } + + #[test] + fn materializes_nested_sidecar_and_reuses_it() -> Result<()> { + let temp = tempfile::tempdir()?; + let cli = temp_cli(temp.path()); + let dyndep = sidecar(".netsuke/dyndep/abc.dd", "ninja_dyndep_version = 1\n"); + + materialize_dyndep_files(&cli, &[dyndep])?; + let final_path = temp.path().join(".netsuke/dyndep/abc.dd"); + ensure_matching(&final_path, "ninja_dyndep_version = 1\n")?; + + // Second run reuses the existing sidecar without error. + materialize_dyndep_files( + &cli, + &[sidecar( + ".netsuke/dyndep/abc.dd", + "ninja_dyndep_version = 1\n", + )], + )?; + Ok(()) + } + + #[test] + fn corrupt_existing_sidecar_is_reported() -> Result<()> { + let temp = tempfile::tempdir()?; + let cli = temp_cli(temp.path()); + let final_path = temp.path().join(".netsuke/dyndep/bad.dd"); + fs::create_dir_all(final_path.parent().expect("parent exists"))?; + fs::write(&final_path, "corrupt")?; + + let result = + materialize_dyndep_files(&cli, &[sidecar(".netsuke/dyndep/bad.dd", "expected")]); + ensure!(result.is_err(), "corrupt sidecar must be reported"); + Ok(()) + } + + #[test] + fn no_temp_files_left_behind() -> Result<()> { + let temp = tempfile::tempdir()?; + let cli = temp_cli(temp.path()); + materialize_dyndep_files(&cli, &[sidecar(".netsuke/dyndep/x.dd", "content")])?; + let dir = temp.path().join(".netsuke/dyndep"); + let leftovers: Vec<_> = fs::read_dir(&dir)? + .filter_map(|e| e.ok()) + .map(|e| e.file_name().to_string_lossy().into_owned()) + .filter(|n| n.ends_with(".tmp")) + .collect(); + ensure!( + leftovers.is_empty(), + "temp files left behind: {leftovers:?}" + ); + Ok(()) + } + + fn ensure_matching(path: &std::path::Path, expected: &str) -> Result<()> { + anyhow::ensure!( + fs::read_to_string(path)? == expected, + "sidecar content does not match" + ); + Ok(()) + } +} diff --git a/src/runner/process/mod.rs b/src/runner/process/mod.rs index 9ae69b962..21ec4ab89 100644 --- a/src/runner/process/mod.rs +++ b/src/runner/process/mod.rs @@ -9,6 +9,7 @@ use std::{io, path::Path, process::Command}; mod child_exit; mod command_list_telemetry; mod command_logging; +mod dyndep_files; mod failure_attribution; mod file_io; mod ninja_program; @@ -32,6 +33,15 @@ pub use ninja_program::resolve_ninja_program_utf8; use ninja_program::{resolve_ninja_program_utf8_with, resolve_ninja_program_with}; use output_forwarding::{StatusObserver, spawn_and_stream_output}; + +}; +pub use dyndep_files::materialize_dyndep_files; +pub use file_io::*; +pub use ninja_program::resolve_ninja_program; +#[cfg(doctest)] +pub use ninja_program::resolve_ninja_program_utf8; +#[cfg(test)] + mod command_env; mod configure; mod request; diff --git a/tests/serial_dependency_runtime_tests.rs b/tests/serial_dependency_runtime_tests.rs new file mode 100644 index 000000000..679fc501d --- /dev/null +++ b/tests/serial_dependency_runtime_tests.rs @@ -0,0 +1,236 @@ +//! Real-Ninja runtime tests for serial dependency ordering. +//! +//! These tests drive an actual `ninja` process against a bundle generated by +//! `generate_bundle`, using filesystem markers to prove declaration order, +//! failure short-circuiting, and shared-work reuse. They deliberately assert +//! observable behaviour rather than generated text. + +use anyhow::{Context, Result, ensure}; +use camino::Utf8PathBuf; +use netsuke::ast::{DependencyOrder, Recipe}; +use netsuke::ir::{Action, BuildEdge, BuildGraph}; +use netsuke::ninja_gen::generate_bundle; +use std::process::Command; +use tempfile::TempDir; + +const NINJA: &str = "ninja"; + +fn action(command: &str) -> Action { + Action { + recipe: Recipe::Command { + command: command.into(), + }, + description: None, + depfile: None, + deps_format: None, + pool: None, + restat: false, + } +} + +fn edge(output: &str, deps: &[&str], order: DependencyOrder) -> BuildEdge { + let implicit_deps: Vec<_> = deps.iter().map(Utf8PathBuf::from).collect(); + BuildEdge { + action_id: "dep-rule".into(), + inputs: Vec::new(), + implicit_deps, + dependency_order: order, + explicit_outputs: vec![Utf8PathBuf::from(output)], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + } +} + +/// Write a bundle into `dir` (sidecars beneath `.netsuke/dyndep`) and return +/// the main build file path. +fn stage_bundle(dir: &TempDir, bundle: &netsuke::ninja_gen::GeneratedNinja) -> Result { + for dd in bundle.dyndep_files() { + let path = dir.path().join(dd.relative_path()); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent) + .with_context(|| format!("create sidecar dir {}", parent.display()))?; + } + std::fs::write(&path, dd.content()) + .with_context(|| format!("write sidecar {}", path.display()))?; + } + let main = dir.path().join("build.ninja"); + std::fs::write(&main, bundle.build_file()).context("write main ninja file")?; + Utf8PathBuf::from_path_buf(main).map_err(|_| anyhow::anyhow!("main path utf8")) +} + +fn run_ninja(dir: &TempDir, main: &Utf8PathBuf) -> Result { + Command::new(NINJA) + .arg("-C") + .arg(dir.path()) + .arg("-f") + .arg(main.as_str()) + .arg("all") + .output() + .context("spawn ninja") +} + +#[test] +fn serial_deps_run_in_declaration_order() -> Result<()> { + let dir = tempfile::tempdir()?; + let mut graph = BuildGraph::default(); + + graph + .actions + .insert("fmt".into(), action("echo one >> order.log")); + graph + .actions + .insert("lint".into(), action("echo two >> order.log")); + graph + .actions + .insert("test".into(), action("echo three >> order.log")); + graph + .actions + .insert("all".into(), action("echo all >> order.log")); + + graph.targets.insert( + Utf8PathBuf::from("check-fmt"), + BuildEdge { + action_id: "fmt".into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec![Utf8PathBuf::from("check-fmt")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + }, + ); + graph.targets.insert( + Utf8PathBuf::from("lint"), + BuildEdge { + action_id: "lint".into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec![Utf8PathBuf::from("lint")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + }, + ); + graph.targets.insert( + Utf8PathBuf::from("test"), + BuildEdge { + action_id: "test".into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec![Utf8PathBuf::from("test")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + }, + ); + graph.targets.insert( + Utf8PathBuf::from("all"), + BuildEdge { + action_id: "all".into(), + inputs: Vec::new(), + implicit_deps: vec!["check-fmt".into(), "lint".into(), "test".into()], + dependency_order: DependencyOrder::Serial, + explicit_outputs: vec![Utf8PathBuf::from("all")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + }, + ); + + let bundle = generate_bundle(&graph)?; + let main = stage_bundle(&dir, &bundle)?; + let out = run_ninja(&dir, &main)?; + ensure!( + out.status.success(), + "ninja failed: {}", + String::from_utf8_lossy(&out.stderr) + ); + let log = std::fs::read_to_string(dir.path().join("order.log"))?; + ensure!( + log.lines().collect::>() == vec!["one", "two", "three", "all"], + "deps ran out of order: {log:?}" + ); + Ok(()) +} + +#[test] +fn failure_of_early_dep_stops_later_stages() -> Result<()> { + let dir = tempfile::tempdir()?; + let mut graph = BuildGraph::default(); + graph.actions.insert("fail".into(), action("exit 1")); + graph + .actions + .insert("later".into(), action("touch later-marker")); + graph + .actions + .insert("all".into(), action("touch all-marker")); + + graph.targets.insert( + Utf8PathBuf::from("first"), + BuildEdge { + action_id: "fail".into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec![Utf8PathBuf::from("first")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + }, + ); + graph.targets.insert( + Utf8PathBuf::from("second"), + BuildEdge { + action_id: "later".into(), + inputs: Vec::new(), + implicit_deps: Vec::new(), + dependency_order: DependencyOrder::Parallel, + explicit_outputs: vec![Utf8PathBuf::from("second")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + }, + ); + graph.targets.insert( + Utf8PathBuf::from("all"), + BuildEdge { + action_id: "all".into(), + inputs: Vec::new(), + implicit_deps: vec!["first".into(), "second".into()], + dependency_order: DependencyOrder::Serial, + explicit_outputs: vec![Utf8PathBuf::from("all")], + implicit_outputs: Vec::new(), + order_only_deps: Vec::new(), + phony: false, + always: false, + }, + ); + + let bundle = generate_bundle(&graph)?; + let main = stage_bundle(&dir, &bundle)?; + let out = run_ninja(&dir, &main)?; + ensure!( + !out.status.success(), + "ninja should fail when the first dep fails" + ); + ensure!( + !dir.path().join("later-marker").exists(), + "later dependency must not run after the first dep fails" + ); + ensure!( + !dir.path().join("all-marker").exists(), + "aggregate must not run after the first dep fails" + ); + Ok(()) +} From 75e6bede65a1d1fae4ae4975703fcdcd346aa627 Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 11 Aug 2026 20:39:37 +0200 Subject: [PATCH 06/69] Group dyndep staging state in one struct Collect the per-edge sidecar list and seen-path set in a single SerialStages value so render_serial_block no longer threads five parameters and cannot diverge from the caller's bundle accumulator. --- src/ninja_gen/dyndep.rs | 31 +++++++++++++++---------------- 1 file changed, 15 insertions(+), 16 deletions(-) diff --git a/src/ninja_gen/dyndep.rs b/src/ninja_gen/dyndep.rs index bcc165fd6..18c519465 100644 --- a/src/ninja_gen/dyndep.rs +++ b/src/ninja_gen/dyndep.rs @@ -171,9 +171,8 @@ pub fn generate_bundle(graph: &BuildGraph) -> Result = graph.targets.values().collect(); edges.sort_by_key(|a| path_key(&a.explicit_outputs)); - let mut seen = HashSet::new(); - let mut dyndep_files: Vec = Vec::new(); - let mut staged_sidecars: HashSet = HashSet::new(); + let mut seen: HashSet = HashSet::new(); + let mut stages = SerialStages::default(); for edge in edges { let key = path_key(&edge.explicit_outputs); @@ -194,14 +193,8 @@ pub fn generate_bundle(graph: &BuildGraph) -> Result 1; if requires_gates { let mut added = Vec::new(); - render_serial_block( - edge, - &mut out, - &mut dyndep_files, - &mut staged_sidecars, - &mut added, - ) - .expect("write to String cannot fail"); + render_serial_block(edge, &mut out, &mut stages, &mut added) + .expect("write to String cannot fail"); let mut aggregate = edge.clone(); aggregate.implicit_deps = added; aggregate.dependency_order = DependencyOrder::Parallel; @@ -235,18 +228,24 @@ pub fn generate_bundle(graph: &BuildGraph) -> Result, + staged_sidecars: HashSet, +} + /// Emit the staged gates and sidecar-producing phony edges for one serial edge, /// collecting each sidecar into the bundle and returning the gate paths in /// dependency order. fn render_serial_block( edge: &BuildEdge, out: &mut String, - dyndep_files: &mut Vec, - staged_sidecars: &mut HashSet, + stages: &mut SerialStages, gate_paths: &mut Vec, ) -> std::fmt::Result { use crate::ninja_gen::escape_ninja_path; @@ -278,8 +277,8 @@ fn render_serial_block( writeln!(out, " dyndep = {sidecar_escaped}")?; writeln!(out)?; - if staged_sidecars.insert(sidecar.clone()) { - dyndep_files.push(GeneratedDyndep { + if stages.staged_sidecars.insert(sidecar.clone()) { + stages.dyndep_files.push(GeneratedDyndep { relative_path: sidecar, content, }); From 6da0a815b50117d7fe8e237ee0922a457dde31ad Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 11 Aug 2026 22:05:16 +0200 Subject: [PATCH 07/69] Harden serial dyndep implementation checks (#552) Propagate bundle formatting errors, apply capability-scoped test staging, and split focused test cases so the serial-dependency implementation meets the repository's strict Clippy and Whitaker contracts. --- src/ninja_gen/dyndep.rs | 31 ++--- src/ninja_gen/dyndep_tests.rs | 37 ++++-- src/runner/process/dyndep_files.rs | 19 +-- tests/ast_tests.rs | 3 + tests/ast_tests/dependency_order.rs | 46 +++++++ tests/ast_tests/parsing.rs | 100 --------------- tests/serial_dependency_runtime_tests.rs | 150 ++++++++++------------- 7 files changed, 164 insertions(+), 222 deletions(-) create mode 100644 tests/ast_tests/dependency_order.rs diff --git a/src/ninja_gen/dyndep.rs b/src/ninja_gen/dyndep.rs index 18c519465..e6d448570 100644 --- a/src/ninja_gen/dyndep.rs +++ b/src/ninja_gen/dyndep.rs @@ -85,7 +85,7 @@ impl GeneratedDyndep { /// Borrow the sidecar path relative to the effective Ninja working /// directory. #[must_use] - pub fn relative_path(&self) -> &Utf8PathBuf { + pub const fn relative_path(&self) -> &Utf8PathBuf { &self.relative_path } @@ -159,14 +159,14 @@ pub fn generate_bundle(graph: &BuildGraph) -> Result = graph.actions.iter().collect(); actions.sort_by_key(|(id, _)| *id); for (id, action) in actions { use crate::ninja_gen::NamedAction; - writeln!(out, "{}", NamedAction { id, action }).expect("write to String cannot fail"); + writeln!(out, "{}", NamedAction { id, action })?; } let mut edges: Vec<_> = graph.targets.values().collect(); @@ -193,8 +193,7 @@ pub fn generate_bundle(graph: &BuildGraph) -> Result 1; if requires_gates { let mut added = Vec::new(); - render_serial_block(edge, &mut out, &mut stages, &mut added) - .expect("write to String cannot fail"); + render_serial_block(edge, &mut out, &mut stages, &mut added)?; let mut aggregate = edge.clone(); aggregate.implicit_deps = added; aggregate.dependency_order = DependencyOrder::Parallel; @@ -205,8 +204,7 @@ pub fn generate_bundle(graph: &BuildGraph) -> Result Result Result<(), NinjaGenError> { .chain(&edge.order_only_deps) { let as_str = path.as_str(); - if as_str == SERIAL_NAMESPACE - || as_str == DYNDEP_NAMESPACE - || as_str.starts_with(&format!("{SERIAL_NAMESPACE}/")) - || as_str.starts_with(&format!("{DYNDEP_NAMESPACE}/")) - { + let is_reserved = [SERIAL_NAMESPACE, DYNDEP_NAMESPACE] + .iter() + .any(|namespace| { + as_str == *namespace + || as_str + .strip_prefix(namespace) + .is_some_and(|suffix| suffix.starts_with('/')) + }); + if is_reserved { return Err(NinjaGenError::ReservedOutputPath { path: path.clone(), message: localization::message(keys::NINJA_GEN_RESERVED_OUTPUT_PATH) diff --git a/src/ninja_gen/dyndep_tests.rs b/src/ninja_gen/dyndep_tests.rs index 41821a92c..7d70d1c9a 100644 --- a/src/ninja_gen/dyndep_tests.rs +++ b/src/ninja_gen/dyndep_tests.rs @@ -20,7 +20,7 @@ fn action(command: &str) -> Action { } fn serial_edge(output: &str, deps: &[&str]) -> BuildEdge { - let implicit_deps: Vec<_> = deps.iter().map(|d| Utf8PathBuf::from(d)).collect(); + let implicit_deps: Vec<_> = deps.iter().map(Utf8PathBuf::from).collect(); BuildEdge { action_id: "a".into(), inputs: Vec::new(), @@ -40,16 +40,21 @@ fn parallel_edge(output: &str, deps: &[&str]) -> BuildEdge { edge } -fn graph_with_edge(edge: BuildEdge) -> BuildGraph { +fn graph_with_edge(edge: BuildEdge) -> Result { let mut graph = BuildGraph::default(); graph.actions.insert("a".into(), action("echo done")); - graph.targets.insert(edge.explicit_outputs[0].clone(), edge); - graph + let output = edge + .explicit_outputs + .first() + .cloned() + .context("test edge must have an output")?; + graph.targets.insert(output, edge); + Ok(graph) } #[test] fn serial_bundle_emits_version_and_staged_sidecars() -> Result<()> { - let graph = graph_with_edge(serial_edge("all", &["check-fmt", "lint", "test"])); + let graph = graph_with_edge(serial_edge("all", &["check-fmt", "lint", "test"]))?; let bundle = generate_bundle(&graph)?; ensure!( bundle @@ -86,9 +91,13 @@ fn serial_bundle_emits_version_and_staged_sidecars() -> Result<()> { #[test] fn serial_sidecars_reveal_real_deps_in_order() -> Result<()> { - let graph = graph_with_edge(serial_edge("all", &["check-fmt", "lint", "test"])); + let graph = graph_with_edge(serial_edge("all", &["check-fmt", "lint", "test"]))?; let bundle = generate_bundle(&graph)?; - let contents: Vec<&str> = bundle.dyndep_files().iter().map(|d| d.content()).collect(); + let contents: Vec<&str> = bundle + .dyndep_files() + .iter() + .map(GeneratedDyndep::content) + .collect(); // Sidecar order follows declaration order because the first sidecar has no // predecessor while later sidecars are produced by ordered edges. let fmt_at = contents @@ -112,7 +121,7 @@ fn serial_sidecars_reveal_real_deps_in_order() -> Result<()> { #[test] fn parallel_edges_produce_no_sidecars() -> Result<()> { - let graph = graph_with_edge(parallel_edge("all", &["dep1", "dep2"])); + let graph = graph_with_edge(parallel_edge("all", &["dep1", "dep2"]))?; let bundle = generate_bundle(&graph)?; ensure!( !bundle.build_file().contains("ninja_required_version"), @@ -127,7 +136,7 @@ fn parallel_edges_produce_no_sidecars() -> Result<()> { #[test] fn one_element_serial_list_needs_no_gates() -> Result<()> { - let graph = graph_with_edge(serial_edge("all", &["dep1"])); + let graph = graph_with_edge(serial_edge("all", &["dep1"]))?; let bundle = generate_bundle(&graph)?; ensure!( !bundle.build_file().contains("ninja_required_version"), @@ -142,7 +151,7 @@ fn one_element_serial_list_needs_no_gates() -> Result<()> { #[test] fn repeated_dependency_keeps_separate_stage_sidecars() -> Result<()> { - let graph = graph_with_edge(serial_edge("all", &["same", "same"])); + let graph = graph_with_edge(serial_edge("all", &["same", "same"]))?; let bundle = generate_bundle(&graph)?; // Each gate stage is distinct, so each stage has its own content-addressed // sidecar even when the revealed dependency is the same node. Ninja @@ -161,7 +170,11 @@ fn repeated_dependency_keeps_separate_stage_sidecars() -> Result<()> { "each stage needs its own sidecar, got {}", bundle.dyndep_files().len() ); - let contents: Vec<&str> = bundle.dyndep_files().iter().map(|d| d.content()).collect(); + let contents: Vec<&str> = bundle + .dyndep_files() + .iter() + .map(GeneratedDyndep::content) + .collect(); ensure!( contents.iter().all(|c| c.contains("same")), "every stage sidecar must reveal the shared dependency" @@ -173,7 +186,7 @@ fn repeated_dependency_keeps_separate_stage_sidecars() -> Result<()> { fn reserved_output_namespace_is_rejected() -> Result<()> { let mut edge = parallel_edge("all", &["dep"]); edge.explicit_outputs = vec![Utf8PathBuf::from(".netsuke/serial/x")]; - let graph = graph_with_edge(edge); + let graph = graph_with_edge(edge)?; let err = generate_bundle(&graph) .err() .context("reserved path must be rejected")?; diff --git a/src/runner/process/dyndep_files.rs b/src/runner/process/dyndep_files.rs index 84d157892..336c1cbce 100644 --- a/src/runner/process/dyndep_files.rs +++ b/src/runner/process/dyndep_files.rs @@ -37,7 +37,7 @@ pub fn materialize_dyndep_files(cli: &Cli, dyndep_files: &[GeneratedDyndep]) -> let dir = open_effective_dir(cli)?; dir.create_dir_all(DYNDEP_DIR).with_context(|| { localization::message(keys::RUNNER_IO_DYNDEP_CREATE_DIR) - .with_arg("path", DYNDEP_DIR.to_string()) + .with_arg("path", DYNDEP_DIR.to_owned()) })?; for sidecar in dyndep_files { materialize_one(&dir, sidecar)?; @@ -157,7 +157,7 @@ fn write_atomic(dir: &Dir, rel: &Utf8Path, content: &str) -> Result<()> { localization::message(keys::RUNNER_IO_DYNDEP_RENAME).with_arg("path", rel.as_str()) }); } - let _ = dir.remove_file(&temp); + drop(dir.remove_file(&temp)); } Ok(()) } @@ -169,9 +169,10 @@ fn write_atomic(dir: &Dir, rel: &Utf8Path, content: &str) -> Result<()> { /// re-verification of the final content. fn unique_temp_name(rel: &Utf8Path) -> Utf8PathBuf { let name = rel.file_name().unwrap_or("sidecar.dd"); - rel.parent() - .map(|parent| parent.join(format!("{name}.tmp"))) - .unwrap_or_else(|| Utf8PathBuf::from(format!("{name}.tmp"))) + rel.parent().map_or_else( + || Utf8PathBuf::from(format!("{name}.tmp")), + |parent| parent.join(format!("{name}.tmp")), + ) } #[cfg(test)] @@ -237,9 +238,13 @@ mod tests { materialize_dyndep_files(&cli, &[sidecar(".netsuke/dyndep/x.dd", "content")])?; let dir = temp.path().join(".netsuke/dyndep"); let leftovers: Vec<_> = fs::read_dir(&dir)? - .filter_map(|e| e.ok()) + .filter_map(Result::ok) .map(|e| e.file_name().to_string_lossy().into_owned()) - .filter(|n| n.ends_with(".tmp")) + .filter(|n| { + std::path::Path::new(n) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("tmp")) + }) .collect(); ensure!( leftovers.is_empty(), diff --git a/tests/ast_tests.rs b/tests/ast_tests.rs index fc0856db6..175a90cfb 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/dependency_order.rs"] +mod dependency_order; + #[path = "ast_tests/descriptions.rs"] mod descriptions; #[path = "ast_tests/macros.rs"] diff --git a/tests/ast_tests/dependency_order.rs b/tests/ast_tests/dependency_order.rs new file mode 100644 index 000000000..f6686f8c2 --- /dev/null +++ b/tests/ast_tests/dependency_order.rs @@ -0,0 +1,46 @@ +//! Tests for the manifest `dependency_order` field. + +use anyhow::{Context, Result, ensure}; +use netsuke::ast::DependencyOrder; +use rstest::rstest; + +use super::support::parse_manifest; + +#[rstest] +fn omission_defaults_to_parallel() -> Result<()> { + let manifest = parse_manifest( + r#" + netsuke_version: "1.0.0" + targets: + - name: all + command: echo done + deps: [check-fmt, test] + "#, + )?; + let target = manifest.targets.first().context("expected target entry")?; + ensure!(target.dependency_order == DependencyOrder::Parallel); + Ok(()) +} + +#[rstest] +#[case::parallel("parallel", DependencyOrder::Parallel)] +#[case::serial("serial", DependencyOrder::Serial)] +fn explicit_value_parses(#[case] value: &str, #[case] expected: DependencyOrder) -> Result<()> { + let manifest = parse_manifest(&format!( + "netsuke_version: \"1.0.0\"\ntargets:\n - name: all\n command: echo done\n dependency_order: {value}\n" + ))?; + let target = manifest.targets.first().context("expected target entry")?; + ensure!(target.dependency_order == expected); + Ok(()) +} + +#[rstest] +#[case::target("targets:\n - name: all\n command: echo done\n dependency_order: sequential")] +#[case::action( + "actions:\n - name: setup\n command: echo hi\n dependency_order: sequential\ntargets:\n - name: done\n command: echo done" +)] +fn unknown_value_is_rejected(#[case] entities: &str) -> Result<()> { + let yaml = format!("netsuke_version: \"1.0.0\"\n{entities}\n"); + ensure!(parse_manifest(&yaml).is_err()); + Ok(()) +} diff --git a/tests/ast_tests/parsing.rs b/tests/ast_tests/parsing.rs index 834f55a50..8c01cf77e 100644 --- a/tests/ast_tests/parsing.rs +++ b/tests/ast_tests/parsing.rs @@ -376,103 +376,3 @@ fn phony_and_always_flags() -> Result<()> { } Ok(()) } - -#[rstest] -fn dependency_order_omission_defaults_to_parallel() -> Result<()> { - let yaml = r#" - netsuke_version: "1.0.0" - targets: - - name: all - command: echo done - deps: - - check-fmt - - test - "#; - let manifest = parse_manifest(yaml)?; - let target = manifest.targets.first().context("expected target entry")?; - ensure!( - target.dependency_order == netsuke::ast::DependencyOrder::Parallel, - "omission should default to parallel, got {:?}", - target.dependency_order - ); - Ok(()) -} - -#[rstest] -fn dependency_order_explicit_parallel_parses() -> Result<()> { - let yaml = r#" - netsuke_version: "1.0.0" - targets: - - name: all - command: echo done - dependency_order: parallel - deps: - - check-fmt - "#; - let manifest = parse_manifest(yaml)?; - let target = manifest.targets.first().context("expected target entry")?; - ensure!( - target.dependency_order == netsuke::ast::DependencyOrder::Parallel, - "explicit parallel should parse as Parallel, got {:?}", - target.dependency_order - ); - Ok(()) -} - -#[rstest] -fn dependency_order_explicit_serial_parses() -> Result<()> { - let yaml = r#" - netsuke_version: "1.0.0" - targets: - - name: all - command: echo done - dependency_order: serial - deps: - - check-fmt - - lint - - test - "#; - let manifest = parse_manifest(yaml)?; - let target = manifest.targets.first().context("expected target entry")?; - ensure!( - target.dependency_order == netsuke::ast::DependencyOrder::Serial, - "serial should parse as Serial, got {:?}", - target.dependency_order - ); - Ok(()) -} - -#[rstest] -fn dependency_order_unknown_value_rejected() -> Result<()> { - let yaml = r#" - netsuke_version: "1.0.0" - targets: - - name: all - command: echo done - dependency_order: sequential - "#; - ensure!( - parse_manifest(yaml).is_err(), - "unknown dependency_order value should be rejected" - ); - Ok(()) -} - -#[rstest] -fn dependency_order_unknown_value_rejected_for_actions() -> Result<()> { - let yaml = r#" - netsuke_version: "1.0.0" - actions: - - name: setup - command: echo hi - dependency_order: sequential - targets: - - name: done - command: echo done - "#; - ensure!( - parse_manifest(yaml).is_err(), - "actions with unknown dependency_order value should be rejected" - ); - Ok(()) -} diff --git a/tests/serial_dependency_runtime_tests.rs b/tests/serial_dependency_runtime_tests.rs index 679fc501d..83598084e 100644 --- a/tests/serial_dependency_runtime_tests.rs +++ b/tests/serial_dependency_runtime_tests.rs @@ -7,6 +7,7 @@ use anyhow::{Context, Result, ensure}; use camino::Utf8PathBuf; +use cap_std::{ambient_authority, fs_utf8::Dir}; use netsuke::ast::{DependencyOrder, Recipe}; use netsuke::ir::{Action, BuildEdge, BuildGraph}; use netsuke::ninja_gen::generate_bundle; @@ -28,13 +29,17 @@ fn action(command: &str) -> Action { } } -fn edge(output: &str, deps: &[&str], order: DependencyOrder) -> BuildEdge { - let implicit_deps: Vec<_> = deps.iter().map(Utf8PathBuf::from).collect(); +fn edge( + action_id: &str, + output: &str, + deps: &[&str], + dependency_order: DependencyOrder, +) -> BuildEdge { BuildEdge { - action_id: "dep-rule".into(), + action_id: action_id.into(), inputs: Vec::new(), - implicit_deps, - dependency_order: order, + implicit_deps: deps.iter().map(Utf8PathBuf::from).collect(), + dependency_order, explicit_outputs: vec![Utf8PathBuf::from(output)], implicit_outputs: Vec::new(), order_only_deps: Vec::new(), @@ -43,21 +48,57 @@ fn edge(output: &str, deps: &[&str], order: DependencyOrder) -> BuildEdge { } } +fn serial_order_graph() -> BuildGraph { + let mut graph = BuildGraph::default(); + for (name, command) in [ + ("fmt", "echo one >> order.log"), + ("lint", "echo two >> order.log"), + ("test", "echo three >> order.log"), + ("all", "echo all >> order.log"), + ] { + graph.actions.insert(name.into(), action(command)); + } + for (action_id, output, deps, dependency_order) in [ + ("fmt", "check-fmt", &[][..], DependencyOrder::Parallel), + ("lint", "lint", &[][..], DependencyOrder::Parallel), + ("test", "test", &[][..], DependencyOrder::Parallel), + ( + "all", + "all", + &["check-fmt", "lint", "test"][..], + DependencyOrder::Serial, + ), + ] { + graph.targets.insert( + Utf8PathBuf::from(output), + edge(action_id, output, deps, dependency_order), + ); + } + graph +} + /// Write a bundle into `dir` (sidecars beneath `.netsuke/dyndep`) and return /// the main build file path. fn stage_bundle(dir: &TempDir, bundle: &netsuke::ninja_gen::GeneratedNinja) -> Result { + let root = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).map_err(|path| { + anyhow::anyhow!("temporary Ninja directory is not UTF-8: {}", path.display()) + })?; + let root_dir = Dir::open_ambient_dir(&root, ambient_authority()) + .context("open temporary Ninja directory")?; for dd in bundle.dyndep_files() { - let path = dir.path().join(dd.relative_path()); - if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent) - .with_context(|| format!("create sidecar dir {}", parent.display()))?; + if let Some(parent) = dd.relative_path().parent() { + root_dir + .create_dir_all(parent) + .with_context(|| format!("create sidecar dir {parent}"))?; } - std::fs::write(&path, dd.content()) - .with_context(|| format!("write sidecar {}", path.display()))?; + root_dir + .write(dd.relative_path(), dd.content()) + .with_context(|| format!("write sidecar {}", dd.relative_path()))?; } - let main = dir.path().join("build.ninja"); - std::fs::write(&main, bundle.build_file()).context("write main ninja file")?; - Utf8PathBuf::from_path_buf(main).map_err(|_| anyhow::anyhow!("main path utf8")) + root_dir + .write("build.ninja", bundle.build_file()) + .context("write main ninja file")?; + Ok(root.join("build.ninja")) } fn run_ninja(dir: &TempDir, main: &Utf8PathBuf) -> Result { @@ -74,79 +115,7 @@ fn run_ninja(dir: &TempDir, main: &Utf8PathBuf) -> Result #[test] fn serial_deps_run_in_declaration_order() -> Result<()> { let dir = tempfile::tempdir()?; - let mut graph = BuildGraph::default(); - - graph - .actions - .insert("fmt".into(), action("echo one >> order.log")); - graph - .actions - .insert("lint".into(), action("echo two >> order.log")); - graph - .actions - .insert("test".into(), action("echo three >> order.log")); - graph - .actions - .insert("all".into(), action("echo all >> order.log")); - - graph.targets.insert( - Utf8PathBuf::from("check-fmt"), - BuildEdge { - action_id: "fmt".into(), - inputs: Vec::new(), - implicit_deps: Vec::new(), - dependency_order: DependencyOrder::Parallel, - explicit_outputs: vec![Utf8PathBuf::from("check-fmt")], - implicit_outputs: Vec::new(), - order_only_deps: Vec::new(), - phony: false, - always: false, - }, - ); - graph.targets.insert( - Utf8PathBuf::from("lint"), - BuildEdge { - action_id: "lint".into(), - inputs: Vec::new(), - implicit_deps: Vec::new(), - dependency_order: DependencyOrder::Parallel, - explicit_outputs: vec![Utf8PathBuf::from("lint")], - implicit_outputs: Vec::new(), - order_only_deps: Vec::new(), - phony: false, - always: false, - }, - ); - graph.targets.insert( - Utf8PathBuf::from("test"), - BuildEdge { - action_id: "test".into(), - inputs: Vec::new(), - implicit_deps: Vec::new(), - dependency_order: DependencyOrder::Parallel, - explicit_outputs: vec![Utf8PathBuf::from("test")], - implicit_outputs: Vec::new(), - order_only_deps: Vec::new(), - phony: false, - always: false, - }, - ); - graph.targets.insert( - Utf8PathBuf::from("all"), - BuildEdge { - action_id: "all".into(), - inputs: Vec::new(), - implicit_deps: vec!["check-fmt".into(), "lint".into(), "test".into()], - dependency_order: DependencyOrder::Serial, - explicit_outputs: vec![Utf8PathBuf::from("all")], - implicit_outputs: Vec::new(), - order_only_deps: Vec::new(), - phony: false, - always: false, - }, - ); - - let bundle = generate_bundle(&graph)?; + let bundle = generate_bundle(&serial_order_graph())?; let main = stage_bundle(&dir, &bundle)?; let out = run_ninja(&dir, &main)?; ensure!( @@ -154,7 +123,12 @@ fn serial_deps_run_in_declaration_order() -> Result<()> { "ninja failed: {}", String::from_utf8_lossy(&out.stderr) ); - let log = std::fs::read_to_string(dir.path().join("order.log"))?; + let root_path = Utf8PathBuf::from_path_buf(dir.path().to_path_buf()).map_err(|path| { + anyhow::anyhow!("temporary Ninja directory is not UTF-8: {}", path.display()) + })?; + let root = Dir::open_ambient_dir(&root_path, ambient_authority()) + .context("open temporary Ninja directory")?; + let log = root.read_to_string("order.log").context("read order log")?; ensure!( log.lines().collect::>() == vec!["one", "two", "three", "all"], "deps ran out of order: {log:?}" From 1e1f3b7fb7b67b0eb64fe857343cc0b31a9af64d Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 11 Aug 2026 22:10:05 +0200 Subject: [PATCH 08/69] Use capabilities in dyndep materializer tests (#552) Keep sidecar-materialization fixtures within their temporary directory capability so they satisfy the repository filesystem policy. --- src/runner/process/dyndep_files.rs | 39 +++++++++++++++--------------- 1 file changed, 19 insertions(+), 20 deletions(-) diff --git a/src/runner/process/dyndep_files.rs b/src/runner/process/dyndep_files.rs index 336c1cbce..d2be22e82 100644 --- a/src/runner/process/dyndep_files.rs +++ b/src/runner/process/dyndep_files.rs @@ -183,7 +183,6 @@ mod tests { use crate::ninja_gen::GeneratedDyndep; use anyhow::{Result, ensure}; use camino::Utf8PathBuf; - use std::fs; fn temp_cli(dir: &std::path::Path) -> Cli { Cli { @@ -196,6 +195,12 @@ mod tests { GeneratedDyndep::fixture(Utf8PathBuf::from(name), content.to_owned()) } + fn temp_dir(temp: &tempfile::TempDir) -> Result { + let path = Utf8PathBuf::from_path_buf(temp.path().to_path_buf()) + .map_err(|path| anyhow!("temporary directory is not UTF-8: {}", path.display()))?; + Dir::open_ambient_dir(path, ambient_authority()).map_err(Into::into) + } + #[test] fn materializes_nested_sidecar_and_reuses_it() -> Result<()> { let temp = tempfile::tempdir()?; @@ -203,8 +208,11 @@ mod tests { let dyndep = sidecar(".netsuke/dyndep/abc.dd", "ninja_dyndep_version = 1\n"); materialize_dyndep_files(&cli, &[dyndep])?; - let final_path = temp.path().join(".netsuke/dyndep/abc.dd"); - ensure_matching(&final_path, "ninja_dyndep_version = 1\n")?; + ensure_matching( + &temp_dir(&temp)?, + ".netsuke/dyndep/abc.dd", + "ninja_dyndep_version = 1\n", + )?; // Second run reuses the existing sidecar without error. materialize_dyndep_files( @@ -221,9 +229,9 @@ mod tests { fn corrupt_existing_sidecar_is_reported() -> Result<()> { let temp = tempfile::tempdir()?; let cli = temp_cli(temp.path()); - let final_path = temp.path().join(".netsuke/dyndep/bad.dd"); - fs::create_dir_all(final_path.parent().expect("parent exists"))?; - fs::write(&final_path, "corrupt")?; + let dir = temp_dir(&temp)?; + dir.create_dir_all(DYNDEP_DIR)?; + dir.write(".netsuke/dyndep/bad.dd", "corrupt")?; let result = materialize_dyndep_files(&cli, &[sidecar(".netsuke/dyndep/bad.dd", "expected")]); @@ -236,26 +244,17 @@ mod tests { let temp = tempfile::tempdir()?; let cli = temp_cli(temp.path()); materialize_dyndep_files(&cli, &[sidecar(".netsuke/dyndep/x.dd", "content")])?; - let dir = temp.path().join(".netsuke/dyndep"); - let leftovers: Vec<_> = fs::read_dir(&dir)? - .filter_map(Result::ok) - .map(|e| e.file_name().to_string_lossy().into_owned()) - .filter(|n| { - std::path::Path::new(n) - .extension() - .is_some_and(|extension| extension.eq_ignore_ascii_case("tmp")) - }) - .collect(); + let temp_file = ".netsuke/dyndep/x.dd.tmp"; ensure!( - leftovers.is_empty(), - "temp files left behind: {leftovers:?}" + temp_dir(&temp)?.open(temp_file).is_err(), + "temp file left behind" ); Ok(()) } - fn ensure_matching(path: &std::path::Path, expected: &str) -> Result<()> { + fn ensure_matching(dir: &Dir, path: &str, expected: &str) -> Result<()> { anyhow::ensure!( - fs::read_to_string(path)? == expected, + dir.read_to_string(path)? == expected, "sidecar content does not match" ); Ok(()) From e98a7e82c5e030b7f958966ce0987fe8a870e39f Mon Sep 17 00:00:00 2001 From: leynos Date: Tue, 11 Aug 2026 22:10:50 +0200 Subject: [PATCH 09/69] Record serial dependency validation progress (#552) Update the approved ExecPlan with implementation and validation findings, and remove surplus trailing whitespace from the affected Fluent catalogues. --- ...ndency-ordering-for-actions-and-targets.md | 100 ++++++++++++------ locales/ar/messages.ftl | 3 - 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 | 3 - locales/fi/messages.ftl | 2 - locales/fr/messages.ftl | 2 - locales/gd/messages.ftl | 2 - locales/he/messages.ftl | 3 - 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 - 36 files changed, 69 insertions(+), 104 deletions(-) diff --git a/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md b/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md index 5ff9c9f3a..01069f076 100644 --- a/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md +++ b/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md @@ -4,7 +4,9 @@ This ExecPlan is a living document. Keep `Progress`, `Surprises & discoveries`, `Decision log`, and `Outcomes & retrospective` current as implementation proceeds. -Status: **In development — plan approved by the user on 2026-08-10; implementation in progress.** +Status: **In development — plan approved by the user on 2026-08-10; the +implementation commits are complete and documentation, final validation, +review, and publication remain.** Issue: [#552](https://github.com/leynos/netsuke/issues/552) @@ -184,33 +186,48 @@ criterion in issue #552 and all repository gates pass. and fixture to compile. `cargo check --all-targets` and 739 lib + touched integration tests pass. - [x] (2026-08-10) Implemented deterministic Ninja bundle and dyndep lowering: - the new `src/ninja_gen/dyndep.rs` submodule adds `GeneratedNinja` (main - text plus content-addressed `GeneratedDyndep` sidecars) and - `generate_bundle`. Serial multi-dependency edges lower into one phony gate - and sidecar per dependency; the version floor is emitted only when gates - exist; sidecars are content-addressed beneath `.netsuke/dyndep`; and - string-only generation returns `NinjaGenError::DyndepFilesRequired` - without writing partial output. Added reserved-namespace collision errors - and localization keys across all 35 catalogues. Unit, integration, and - doctests pass. + the new `src/ninja_gen/dyndep.rs` submodule adds `GeneratedNinja` (main text + plus content-addressed `GeneratedDyndep` sidecars) and `generate_bundle`. + Serial multi-dependency edges lower into one phony gate and sidecar per + dependency; the version floor is emitted only when gates exist; sidecars are + content-addressed beneath `.netsuke/dyndep`; and string-only generation + returns `NinjaGenError::DyndepFilesRequired` without writing partial output. + Added reserved-namespace collision errors and localization keys across all 35 + catalogues. Unit, integration, and doctests pass. - [x] (2026-08-10) Implemented atomic dyndep sidecar materialization in - every CLI path. `src/runner/process/dyndep_files.rs` materializes the - bundle sidecars beneath `.netsuke/dyndep` relative to the effective Ninja - working directory, using capability-scoped writes, a same-directory - `create_new` temporary file, and an atomic rename. Existing content is - verified and reused; corruption and concurrent-writer outcomes are - covered. `generate_ninja` now routes every build, clean, and generate - invocation through `generate_bundle` plus materialization before invoking - Ninja. Added runtime tests driving real Ninja: strict declaration order, - failure short-circuiting, and materializer idempotence/corruption paths. - Verified end-to-end with a real serial manifest and real Ninja 1.11.1 - (order observed: fmt, lint, test, all). Full suite: 1930 tests pass. + every CLI path. `src/runner/process/dyndep_files.rs` materializes the bundle + sidecars beneath `.netsuke/dyndep` relative to the effective Ninja working + directory, using capability-scoped writes, a same-directory `create_new` + temporary file, and an atomic rename. Existing content is verified and + reused; corruption and concurrent-writer outcomes are covered. + `generate_ninja` now routes every build, clean, and generate invocation + through `generate_bundle` plus materialization before invoking Ninja. Added + runtime tests driving real Ninja: strict declaration order, failure + short-circuiting, and materializer idempotence/corruption paths. Verified + end-to-end with a real serial manifest and real Ninja 1.11.1 (order observed: + fmt, lint, test, all). Full suite: 1930 tests pass. +- [x] (2026-08-11 19:43Z) Re-read this plan, the issue, current implementation + commits, the decision-record convention, and every documentation target named + in Stage 6 before beginning the documentation milestone. Confirmed that the + serial implementation uses the planned AST-to-IR-to-bundle-to-materializer + flow without a scheduler or new dependency. - [ ] Complete user, design, developer, layout, roadmap, and ADR documentation. - [ ] Run focused verification and all repository gates. - [ ] Commit each green logical change and record final evidence here. ## Surprises and discoveries +- (2026-08-11) The prior materializer commit accidentally left surplus blank + lines at EOF in each changed Fluent catalogue. `git show --check` reports + them even though the current worktree is clean. Remove only those trailing + blank lines in a preparatory cleanup before the next full validation run. +- (2026-08-11) The first fresh full-gate run stopped before review: typecheck + found an unused runtime-test helper, and Clippy found `expect` calls in + fallible bundle formatting plus three small idiom violations in the + materializer. `check-fmt`, Markdown linting, and Mermaid validation passed. + The correction remains within the approved implementation and needs no new + dependency or architecture. + - (2026-08-10) Re-validated the staged dyndep chain with real Ninja 1.11.1: declaration order holds when all sidecars are pre-materialized; a later sidecar is revealed only after the preceding gate; failure of an early real @@ -219,18 +236,19 @@ criterion in issue #552 and all repository gates pass. process. - (2026-08-10) Ninja path escaping in build/dyndep documents uses `$` as the escape character. Spaces, `$`, `:`, `|`, and similar metacharacters in target - or dependency paths must be escaped as `$ ` (space is `$ `), `$$` for a - literal dollar, `$:` for colon, `$|` for pipe. Unescaped spaces split a token - into multiple paths. The generator therefore needs a dedicated Ninja - path-escape helper distinct from the existing shell-script escaping. + or dependency paths must use Ninja's dollar escape: a dollar sign followed by + a space for a literal space, `$$` for a literal dollar, `$:` for colon, and + `$|` for pipe. Unescaped spaces split a token into multiple paths. The + generator therefore needs a dedicated Ninja path-escape helper distinct from + the existing shell-script escaping. - (2026-08-10) Ninja resolves every path named in a build file — including a `dyndep =` value and every path inside the referenced dyndep document — relative to Ninja's process working directory, which is the `-C` directory when one is supplied. The directory containing the main build file does not - affect path resolution. Confirmed with Ninja 1.11.1: with the main build - file in an OS temp directory and `-C` set to the user's project directory, - a sidecar written beneath `project/.netsuke/dyndep/` is located, loaded, and + affect path resolution. Confirmed with Ninja 1.11.1: with the main build file + in an OS temp directory and `-C` set to the user's project directory, a + sidecar written beneath `project/.netsuke/dyndep/` is located, loaded, and its revealed dependency built correctly. The runner therefore needs no architectural change; the plan's existing `.netsuke` navigation already matches Ninja's model. @@ -264,6 +282,18 @@ criterion in issue #552 and all repository gates pass. ## Decision log +- **Decision:** retain the existing AST, IR, Ninja generation, runner, and + localization implementation commits as the reviewed functional baseline, then + repair only their trailing-catalogue-whitespace defect before full gates. + **Rationale:** the defect does not alter Fluent messages or behaviour, but a + clean diff is required before the first post-implementation review. **Date:** + 2026-08-11. +- **Decision:** propagate `fmt::Error` through the existing + `NinjaGenError::Format` conversion instead of asserting that `String` + formatting cannot fail. **Rationale:** this keeps the generator's public + error contract intact and satisfies the repository's no-`expect` policy + without adding an abstraction. **Date:** 2026-08-11. + - **Decision:** use staged Ninja dyndep files rather than an order-only gate chain, a pool, or recursive builds. **Rationale:** it is the only evaluated design that keeps a single scheduler, prevents later dependencies from @@ -309,9 +339,12 @@ criterion in issue #552 and all repository gates pass. ## Outcomes and retrospective -No implementation has started. On completion, replace this paragraph with the -observable behaviour delivered, gate results, commit identifiers, deviations -from the plan, and lessons for future scheduling features. +The implementation has delivered the planned closed schema enum, backend-only +staged dyndep lowering, complete generated bundle, and capability-scoped, +atomic sidecar materialization. The user-facing and maintainer documentation, +the durable ADR, fresh full-gate evidence, independent review, and publication +remain. The final retrospective will record those results, commit identifiers, +and any review-driven corrections. ## Context and orientation @@ -885,3 +918,8 @@ confirming their exact APIs in `Cargo.toml` and existing call sites. proposal with a staged dyndep bundle, adds atomic sidecar materialization, and records the independent-reachability limit that must be approved with the implementation approach. + +2026-08-11: Updated the live status after reconciling the committed +implementation with the plan. Added the documentation-preparation evidence and +the narrow trailing-catalogue-whitespace cleanup required before the first full +validation and review. This does not change the remaining implementation scope. diff --git a/locales/ar/messages.ftl b/locales/ar/messages.ftl index 4aa8e2584..29a8a8cbc 100644 --- a/locales/ar/messages.ftl +++ b/locales/ar/messages.ftl @@ -423,6 +423,3 @@ example.errors_found = { $count -> [many] عُثر على { $count } خطأً. *[other] عُثر على { $count } خطأ. } - - - diff --git a/locales/cs/messages.ftl b/locales/cs/messages.ftl index 3dad15cfe..5e9652337 100644 --- a/locales/cs/messages.ftl +++ b/locales/cs/messages.ftl @@ -420,5 +420,3 @@ example.errors_found = { $count -> [many] Nalezeno { $count } chyby. *[other] Nalezeno { $count } chyb. } - - diff --git a/locales/cy/messages.ftl b/locales/cy/messages.ftl index cf455e8b0..3ad43eef1 100644 --- a/locales/cy/messages.ftl +++ b/locales/cy/messages.ftl @@ -423,5 +423,3 @@ example.errors_found = { $count -> [many] Cafwyd hyd i { $count } gwall. *[other] Cafwyd hyd i { $count } gwall. } - - diff --git a/locales/da/messages.ftl b/locales/da/messages.ftl index a1b6dd887..6da61b5a3 100644 --- a/locales/da/messages.ftl +++ b/locales/da/messages.ftl @@ -415,5 +415,3 @@ example.errors_found = { $count -> [one] { $count } fejl fundet. *[other] { $count } fejl fundet. } - - diff --git a/locales/de/messages.ftl b/locales/de/messages.ftl index 7b56664c0..d1d07c8ab 100644 --- a/locales/de/messages.ftl +++ b/locales/de/messages.ftl @@ -415,5 +415,3 @@ example.errors_found = { $count -> [one] { $count } Fehler gefunden. *[other] { $count } Fehler gefunden. } - - diff --git a/locales/el/messages.ftl b/locales/el/messages.ftl index d513ef028..45ff1dace 100644 --- a/locales/el/messages.ftl +++ b/locales/el/messages.ftl @@ -417,5 +417,3 @@ example.errors_found = { $count -> [one] Βρέθηκε { $count } σφάλμα. *[other] Βρέθηκαν { $count } σφάλματα. } - - diff --git a/locales/en-GB/messages.ftl b/locales/en-GB/messages.ftl index 07a170e1b..49d89150e 100644 --- a/locales/en-GB/messages.ftl +++ b/locales/en-GB/messages.ftl @@ -416,5 +416,3 @@ example.errors_found = { $count -> [one] { $count } error found. *[other] { $count } errors found. } - - diff --git a/locales/en-US/messages.ftl b/locales/en-US/messages.ftl index 1f2ac079b..8c6252756 100644 --- a/locales/en-US/messages.ftl +++ b/locales/en-US/messages.ftl @@ -420,5 +420,3 @@ example.errors_found = { $count -> [one] { $count } error found. *[other] { $count } errors found. } - - diff --git a/locales/es-419/messages.ftl b/locales/es-419/messages.ftl index 3f247a47e..aab2bbe5b 100644 --- a/locales/es-419/messages.ftl +++ b/locales/es-419/messages.ftl @@ -418,5 +418,3 @@ example.errors_found = { $count -> [one] Se encontró { $count } error. *[other] Se encontraron { $count } errores. } - - diff --git a/locales/es-ES/messages.ftl b/locales/es-ES/messages.ftl index e11a81b9e..d477724da 100644 --- a/locales/es-ES/messages.ftl +++ b/locales/es-ES/messages.ftl @@ -419,5 +419,3 @@ example.errors_found = { $count -> [one] Se encontró { $count } error. *[other] Se encontraron { $count } errores. } - - diff --git a/locales/fa/messages.ftl b/locales/fa/messages.ftl index 8becd7c55..2b0851ccc 100644 --- a/locales/fa/messages.ftl +++ b/locales/fa/messages.ftl @@ -415,6 +415,3 @@ example.errors_found = { $count -> [one] ‏{ $count } خطا یافت شد. *[other] ‏{ $count } خطا یافت شد. } - - - diff --git a/locales/fi/messages.ftl b/locales/fi/messages.ftl index 0153b9ebc..13955efec 100644 --- a/locales/fi/messages.ftl +++ b/locales/fi/messages.ftl @@ -417,5 +417,3 @@ example.errors_found = { $count -> [one] Löytyi { $count } virhe. *[other] Löytyi { $count } virhettä. } - - diff --git a/locales/fr/messages.ftl b/locales/fr/messages.ftl index cc7f7ae1d..931f7f8d1 100644 --- a/locales/fr/messages.ftl +++ b/locales/fr/messages.ftl @@ -417,5 +417,3 @@ example.errors_found = { $count -> [one] { $count } erreur trouvée. *[other] { $count } erreurs trouvées. } - - diff --git a/locales/gd/messages.ftl b/locales/gd/messages.ftl index 07073d717..cf538e94d 100644 --- a/locales/gd/messages.ftl +++ b/locales/gd/messages.ftl @@ -420,5 +420,3 @@ example.errors_found = { $count -> [few] Chaidh { $count } mearachdan a lorg. *[other] Chaidh { $count } mearachd a lorg. } - - diff --git a/locales/he/messages.ftl b/locales/he/messages.ftl index 74e606259..038d255c6 100644 --- a/locales/he/messages.ftl +++ b/locales/he/messages.ftl @@ -420,6 +420,3 @@ example.errors_found = { $count -> [many] נמצאו { $count } שגיאות. *[other] נמצאו { $count } שגיאות. } - - - diff --git a/locales/hi/messages.ftl b/locales/hi/messages.ftl index ded2422d5..00adbe927 100644 --- a/locales/hi/messages.ftl +++ b/locales/hi/messages.ftl @@ -418,5 +418,3 @@ example.errors_found = { $count -> [one] { $count } त्रुटि मिली। *[other] { $count } त्रुटियाँ मिलीं। } - - diff --git a/locales/hu/messages.ftl b/locales/hu/messages.ftl index 9582c324b..3a8b889db 100644 --- a/locales/hu/messages.ftl +++ b/locales/hu/messages.ftl @@ -417,5 +417,3 @@ example.errors_found = { $count -> [one] { $count } hiba található. *[other] { $count } hiba található. } - - diff --git a/locales/id/messages.ftl b/locales/id/messages.ftl index 72cb3ea0e..70ca14e1c 100644 --- a/locales/id/messages.ftl +++ b/locales/id/messages.ftl @@ -414,5 +414,3 @@ example.errors_found = { $count -> [0] Tidak ada galat yang ditemukan. *[other] { $count } galat ditemukan. } - - diff --git a/locales/it/messages.ftl b/locales/it/messages.ftl index 22a7104b2..9d80867d2 100644 --- a/locales/it/messages.ftl +++ b/locales/it/messages.ftl @@ -416,5 +416,3 @@ example.errors_found = { $count -> [one] Trovato { $count } errore. *[other] Trovati { $count } errori. } - - diff --git a/locales/ja/messages.ftl b/locales/ja/messages.ftl index e9686dbde..ebeae81cb 100644 --- a/locales/ja/messages.ftl +++ b/locales/ja/messages.ftl @@ -413,5 +413,3 @@ example.errors_found = { $count -> [0] エラーは見つかりませんでした。 *[other] { $count } 件のエラーが見つかりました。 } - - diff --git a/locales/ko/messages.ftl b/locales/ko/messages.ftl index 76257af8f..a4c5cf72c 100644 --- a/locales/ko/messages.ftl +++ b/locales/ko/messages.ftl @@ -413,5 +413,3 @@ example.errors_found = { $count -> [0] 오류를 찾지 못했습니다. *[other] 오류 { $count }개를 찾았습니다. } - - diff --git a/locales/nb/messages.ftl b/locales/nb/messages.ftl index ceffc7b1a..a8258e38a 100644 --- a/locales/nb/messages.ftl +++ b/locales/nb/messages.ftl @@ -415,5 +415,3 @@ example.errors_found = { $count -> [one] { $count } feil funnet. *[other] { $count } feil funnet. } - - diff --git a/locales/nl/messages.ftl b/locales/nl/messages.ftl index 78f90a37e..2545edc6f 100644 --- a/locales/nl/messages.ftl +++ b/locales/nl/messages.ftl @@ -416,5 +416,3 @@ example.errors_found = { $count -> [one] { $count } fout gevonden. *[other] { $count } fouten gevonden. } - - diff --git a/locales/pl/messages.ftl b/locales/pl/messages.ftl index baff1290a..1931d79cf 100644 --- a/locales/pl/messages.ftl +++ b/locales/pl/messages.ftl @@ -421,5 +421,3 @@ example.errors_found = { $count -> [many] Znaleziono { $count } błędów. *[other] Znaleziono { $count } błędu. } - - diff --git a/locales/pt-BR/messages.ftl b/locales/pt-BR/messages.ftl index 8d2a7b1ca..e3a9c4510 100644 --- a/locales/pt-BR/messages.ftl +++ b/locales/pt-BR/messages.ftl @@ -417,5 +417,3 @@ example.errors_found = { $count -> [one] { $count } erro encontrado. *[other] { $count } erros encontrados. } - - diff --git a/locales/pt-PT/messages.ftl b/locales/pt-PT/messages.ftl index 36fc6bdc3..adaadd00d 100644 --- a/locales/pt-PT/messages.ftl +++ b/locales/pt-PT/messages.ftl @@ -417,5 +417,3 @@ example.errors_found = { $count -> [one] Foi encontrado { $count } erro. *[other] Foram encontrados { $count } erros. } - - diff --git a/locales/ro/messages.ftl b/locales/ro/messages.ftl index db8bc4eb5..f44c57796 100644 --- a/locales/ro/messages.ftl +++ b/locales/ro/messages.ftl @@ -419,5 +419,3 @@ example.errors_found = { $count -> [few] S-au găsit { $count } erori. *[other] S-au găsit { $count } de erori. } - - diff --git a/locales/ru/messages.ftl b/locales/ru/messages.ftl index dce8c8df7..ea0565801 100644 --- a/locales/ru/messages.ftl +++ b/locales/ru/messages.ftl @@ -422,5 +422,3 @@ example.errors_found = { $count -> [many] Найдено { $count } ошибок. *[other] Найдено { $count } ошибки. } - - diff --git a/locales/sv/messages.ftl b/locales/sv/messages.ftl index fbf096a8b..94dc1fa44 100644 --- a/locales/sv/messages.ftl +++ b/locales/sv/messages.ftl @@ -415,5 +415,3 @@ example.errors_found = { $count -> [one] { $count } fel hittades. *[other] { $count } fel hittades. } - - diff --git a/locales/th/messages.ftl b/locales/th/messages.ftl index f5c0003d5..838995576 100644 --- a/locales/th/messages.ftl +++ b/locales/th/messages.ftl @@ -413,5 +413,3 @@ example.errors_found = { $count -> [0] ไม่พบข้อผิดพลาด *[other] พบข้อผิดพลาด { $count } รายการ } - - diff --git a/locales/tr/messages.ftl b/locales/tr/messages.ftl index 905bef0b3..80d1f74fc 100644 --- a/locales/tr/messages.ftl +++ b/locales/tr/messages.ftl @@ -416,5 +416,3 @@ example.errors_found = { $count -> [one] { $count } hata bulundu. *[other] { $count } hata bulundu. } - - diff --git a/locales/uk/messages.ftl b/locales/uk/messages.ftl index da38e5d6f..b99579b67 100644 --- a/locales/uk/messages.ftl +++ b/locales/uk/messages.ftl @@ -422,5 +422,3 @@ example.errors_found = { $count -> [many] Знайдено { $count } помилок. *[other] Знайдено { $count } помилки. } - - diff --git a/locales/vi/messages.ftl b/locales/vi/messages.ftl index cd7608d50..d94d9db47 100644 --- a/locales/vi/messages.ftl +++ b/locales/vi/messages.ftl @@ -413,5 +413,3 @@ example.errors_found = { $count -> [0] Không tìm thấy lỗi nào. *[other] Tìm thấy { $count } lỗi. } - - diff --git a/locales/zh-Hans/messages.ftl b/locales/zh-Hans/messages.ftl index 71ed4614c..e546a0ac0 100644 --- a/locales/zh-Hans/messages.ftl +++ b/locales/zh-Hans/messages.ftl @@ -412,5 +412,3 @@ example.errors_found = { $count -> [0] 未发现错误。 *[other] 发现 { $count } 个错误。 } - - diff --git a/locales/zh-Hant/messages.ftl b/locales/zh-Hant/messages.ftl index 0c4fef817..2a2c207ee 100644 --- a/locales/zh-Hant/messages.ftl +++ b/locales/zh-Hant/messages.ftl @@ -412,5 +412,3 @@ example.errors_found = { $count -> [0] 未發現錯誤。 *[other] 發現 { $count } 個錯誤。 } - - From 10232ea639b15c11b67ff781f0a2a9d6488cded1 Mon Sep 17 00:00:00 2001 From: leynos Date: Wed, 12 Aug 2026 00:07:16 +0200 Subject: [PATCH 10/69] Document serial dependency ordering (#552) Describe dependency_order for actions and targets, including its path-scoped serial execution guarantee, Ninja version requirement, and generated-state behaviour. Record the dyndep architecture decision and exercise the complete documented manifest through the documentation suite. --- ...a-dyndep-for-serial-dependency-ordering.md | 116 ++++++++++++++++++ docs/contents.md | 3 + docs/developers-guide.md | 37 ++++++ ...ndency-ordering-for-actions-and-targets.md | 50 ++++++-- docs/netsuke-design.md | 26 ++++ docs/repository-layout.md | 9 ++ docs/roadmap.md | 5 + docs/users-guide.md | 65 +++++++++- tests/documentation_examples_tests.rs | 2 + 9 files changed, 302 insertions(+), 11 deletions(-) create mode 100644 docs/adr-010-use-ninja-dyndep-for-serial-dependency-ordering.md diff --git a/docs/adr-010-use-ninja-dyndep-for-serial-dependency-ordering.md b/docs/adr-010-use-ninja-dyndep-for-serial-dependency-ordering.md new file mode 100644 index 000000000..f3c810ee4 --- /dev/null +++ b/docs/adr-010-use-ninja-dyndep-for-serial-dependency-ordering.md @@ -0,0 +1,116 @@ +# Architecture decision record (ADR): Use Ninja dyndep for serial dependency ordering + +## Status + +Accepted. + +## Date + +2026-08-11. + +## Context and problem statement + +Manifest authors need an explicit way to run the direct `deps` of an action or +target in declaration order. The existing Ninja dependency classes preserve +freshness and scheduling constraints, but ordinary implicit dependencies are +all visible to the scheduler and may run concurrently. + +The implementation must retain a single Ninja invocation so a shared dependency +runs once, propagate a failing early dependency to stop later work through the +annotated path, and leave unrelated branches available for normal concurrent +scheduling. It must also leave the Intermediate Representation (IR) +backend-agnostic and make generated Ninja output executable in every command +path. + +## Decision + +In the context of a `dependency_order: serial` manifest `deps` list, Netsuke +will use staged Ninja dyndep sidecars to reveal one direct dependency at a time +and will materialize those sidecars atomically beneath `.netsuke/dyndep` before +writing or invoking the main build file. + +`dependency_order` is a closed `parallel`/`serial` enum on the shared action +and target AST shape. It is copied to `BuildEdge`, where it remains a logical +graph annotation. Only the Ninja generator lowers a serial list containing two +or more direct dependencies into synthetic phony gates beneath +`.netsuke/serial` and content-addressed dyndep sidecars beneath +`.netsuke/dyndep`. + +The main generated build file declares `ninja_required_version = 1.10` only +when staged serial lowering is present. The generator exposes a complete bundle +containing main-file text and every required sidecar. String-only generation +rejects a graph requiring sidecars instead of returning an incomplete file. + +The serial guarantee is deliberately path-scoped: each direct dependency in the +annotated list becomes schedulable only after its predecessor succeeds. A later +dependency independently reachable through another requested path remains free +to run through that other path. + +## Rationale + +- **One scheduler preserves shared work.** The generated gates stay inside one + Ninja invocation, so Ninja continues to deduplicate a repeated or diamond + dependency. +- **Dyndep controls visibility.** A later real dependency is absent from the + relevant graph path until its sidecar is revealed, unlike an order-only edge + whose transitive inputs are already visible to Ninja. +- **The IR remains portable.** Gates, sidecar paths, and Ninja version syntax + are backend mechanics rather than manifest graph concepts. +- **Bundle ownership prevents incomplete output.** Treating sidecars as part of + the generated artefact makes every runner path materialize them before Ninja + loads the main file. +- **Content addressing makes state reusable.** Existing matching sidecars are + safely reused; mismatching content is corruption and is reported rather than + overwritten. + +## Consequences + +- Serial lists with zero or one dependency use ordinary Ninja lowering; no + relative order needs enforcing and no dyndep version floor is emitted. +- User outputs cannot claim `.netsuke/serial` or `.netsuke/dyndep`, because + those names are reserved generated state. +- `build`, `clean`, and `generate` each materialize sidecars relative to the + effective Ninja working directory. `clean` may leave the immutable, + content-addressed sidecars in place. +- `src/ninja_gen/dyndep.rs` owns staging and naming. The runner's + `dyndep_files` module owns capability-scoped, atomic persistence. Neither + module may broaden the path-scoped guarantee with a global scheduler. +- Tests must continue to use real Ninja for ordered starts, failure + short-circuiting, shared-work reuse, and unrelated-branch concurrency. + +## Alternatives considered + +### Order-only phony gate chain + +Rejected. Ninja eagerly schedules already-visible transitive inputs, so an +order-only chain can order gate completion without preventing later real +dependencies from starting early. + +### Ninja pool with depth one + +Rejected. A pool provides mutual exclusion, not declaration order, and would +serialize unrelated work outside the annotated dependency list. + +### Recursive Ninja or Netsuke invocation per dependency + +Rejected. Separate child schedulers lose the enclosing build's memoization and +can execute a shared dependency more than once. + +### A Netsuke-owned global scheduler + +Rejected for this feature. It would change the execution architecture and +global reachability semantics rather than implement the requested scoped +manifest policy. It requires a separately approved design. + +## Implementation references + +- Manifest and IR contract: [`src/ast.rs`](../src/ast.rs), + [`src/ir/graph.rs`](../src/ir/graph.rs), and + [`src/ir/from_manifest.rs`](../src/ir/from_manifest.rs) +- Ninja bundle generation: + [`src/ninja_gen/dyndep.rs`](../src/ninja_gen/dyndep.rs) +- Atomic sidecar materialization: + [`src/runner/process/dyndep_files.rs`](../src/runner/process/dyndep_files.rs) +- User contract: [users guide](users-guide.md#run-direct-dependencies-serially) +- Implementation history: + [issue #552 ExecPlan](execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md) diff --git a/docs/contents.md b/docs/contents.md index 9261ccb42..682ff910f 100644 --- a/docs/contents.md +++ b/docs/contents.md @@ -57,6 +57,9 @@ operator, user, and contributor references are easier to find. - [adr-010-scope-glob-capability-to-literal-prefix.md](adr-010-scope-glob-capability-to-literal-prefix.md): Glob capability-scoping decision record, opening the metadata capability at a pattern's literal directory prefix instead of an ambient root. +- [adr-010-use-ninja-dyndep-for-serial-dependency-ordering.md](adr-010-use-ninja-dyndep-for-serial-dependency-ordering.md): + Serial `deps` ordering decision record, covering staged Ninja dyndep bundles, + their scoped execution guarantee, and generated-state ownership. ## User and operator guides diff --git a/docs/developers-guide.md b/docs/developers-guide.md index 856bec9b8..73676c8b6 100644 --- a/docs/developers-guide.md +++ b/docs/developers-guide.md @@ -1608,11 +1608,48 @@ into `BuildEdge.order_only_deps`. Keep those classes separate: recipe interpolation (`$in` and `{{ ins }}`) receives only `BuildEdge.inputs`, while `src/ninja_gen.rs` renders implicit deps with Ninja's single-pipe separator. +`Target::dependency_order` is a closed manifest enum carried unchanged to +`BuildEdge::dependency_order`. `parallel` is 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. + `src/ir/cycle.rs::CycleDetector::visit` traverses `inputs` and `implicit_deps` when detecting cycles. It intentionally does not traverse `order_only_deps`, because order-only dependencies express scheduling order rather than rebuild freshness. +### Serial dependency bundles + +`src/ninja_gen/dyndep.rs` owns the Ninja-specific lowering for a serial list +with more than one dependency. It produces a `GeneratedNinja` bundle: the main +build-file text plus immutable, content-addressed `GeneratedDyndep` sidecars. +The generated phony gates live under `.netsuke/serial`; sidecars live under +`.netsuke/dyndep`. Those are reserved output namespaces, validated before +generation. A string-only generator must return `DyndepFilesRequired` for a +graph that needs sidecars rather than returning an incomplete build file. + +Each gate reveals one real dependency through a Ninja dyndep file. The next +sidecar-producing edge depends on the preceding gate, which keeps later direct +dependencies unavailable to the scheduler until earlier work succeeds. This is +not an order-only chain or a Ninja pool: both leave the real dependencies +visible to Ninja too early. Preserve one top-level Ninja invocation so shared +nodes keep Ninja's normal execute-once memoization. + +`src/runner/process/dyndep_files.rs` is the sole owner of sidecar persistence. +Every `build`, `clean`, and `generate` path must obtain a bundle and call its +materializer before writing or invoking the main file. It writes through an +effective-working-directory capability, verifies existing content, and uses a +same-directory temporary file plus atomic rename. Keep generated sidecars +content-addressed and idempotent; corruption is an error, not a reason to +overwrite an unknown file. + +The intended serial guarantee is path-scoped. A later dependency that is +independently reachable elsewhere in the requested graph may start via that +other path. Do not broaden the implementation with a global lock, pool, or +new scheduler without an approved design change. See +[ADR-010](adr-010-use-ninja-dyndep-for-serial-dependency-ordering.md) for the +durable decision and its alternatives. + ### Recipe placeholder ownership `src/ir/cmd_interpolate.rs` owns the private `INS_TOKEN` and `OUTS_TOKEN` diff --git a/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md b/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md index 01069f076..9a89af91e 100644 --- a/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md +++ b/docs/execplans/issue-552-support-serial-dependency-ordering-for-actions-and-targets.md @@ -4,9 +4,9 @@ This ExecPlan is a living document. Keep `Progress`, `Surprises & discoveries`, `Decision log`, and `Outcomes & retrospective` current as implementation proceeds. -Status: **In development — plan approved by the user on 2026-08-10; the -implementation commits are complete and documentation, final validation, -review, and publication remain.** +Status: **In development — implementation and documentation changes pass the +full deterministic suite. Commit, independent review, and pull-request refresh +remain.** Issue: [#552](https://github.com/leynos/netsuke/issues/552) @@ -211,8 +211,24 @@ criterion in issue #552 and all repository gates pass. in Stage 6 before beginning the documentation milestone. Confirmed that the serial implementation uses the planned AST-to-IR-to-bundle-to-materializer flow without a scheduler or new dependency. -- [ ] Complete user, design, developer, layout, roadmap, and ADR documentation. -- [ ] Run focused verification and all repository gates. +- [x] (2026-08-11) Documented the manifest syntax and user-visible execution + contract in `docs/users-guide.md`, including the default, serial scope, + failure handling, shared-work behaviour, independent-reachability boundary, + Ninja 1.10 floor, generated sidecars, and reserved paths. Updated the + design, developer, repository-layout, contents, and roadmap documents and + added ADR-010 for the durable backend decision. +- [x] (2026-08-12) Ran the full deterministic suite. Formatting, type checking, + linting, Markdown linting, and Mermaid validation passed; the documentation + example loader rejected the new YAML fence because it lacked a + `tested-example` marker. Made the sample a complete manifest, registered its + stable identifier in the executable-documentation tests, and will rerun the + complete suite before committing. +- [x] (2026-08-12) Re-ran the complete deterministic suite after the + executable-example correction. `make check-fmt`, `make typecheck`, + `make lint`, `make test`, `make markdownlint`, and `make nixie` passed. + `make test` reported 1,939 passed tests, one skipped test, and passing + doctests. The canonical command-specific logs use the current branch suffix + beneath `/tmp`. - [ ] Commit each green logical change and record final evidence here. ## Surprises and discoveries @@ -340,11 +356,12 @@ criterion in issue #552 and all repository gates pass. ## Outcomes and retrospective The implementation has delivered the planned closed schema enum, backend-only -staged dyndep lowering, complete generated bundle, and capability-scoped, -atomic sidecar materialization. The user-facing and maintainer documentation, -the durable ADR, fresh full-gate evidence, independent review, and publication -remain. The final retrospective will record those results, commit identifiers, -and any review-driven corrections. +staged dyndep lowering, complete generated bundle, capability-scoped atomic +sidecar materialization, and the user-facing and maintainer documentation. The +documentation makes the intentionally limited path-scoped execution guarantee +explicit rather than implying global serialization. Fresh full-gate evidence, +independent review, the documentation commit identifier, and final +pull-request refresh remain. ## Context and orientation @@ -923,3 +940,16 @@ implementation approach. implementation with the plan. Added the documentation-preparation evidence and the narrow trailing-catalogue-whitespace cleanup required before the first full validation and review. This does not change the remaining implementation scope. + +2026-08-11: Completed the user and maintainer documentation milestone after +review feedback identified that the implementation-only plan was insufficient +for issue #552 acceptance. ADR-010 records the staged-dyndep decision and the +user guide now states the syntax, guarantees, limitations, version floor, and +generated-state behaviour. Final gates and independent review remain before +completion. + +2026-08-12: The first full documentation gate run exposed the repository's +executable-fence contract. The serial-syntax sample is now a valid standalone +manifest with an explicit marker and an entry in the documentation-example +registry, so its syntax cannot drift without the normal test suite detecting +it. diff --git a/docs/netsuke-design.md b/docs/netsuke-design.md index 6f035e61f..e9ddc53dc 100644 --- a/docs/netsuke-design.md +++ b/docs/netsuke-design.md @@ -382,6 +382,10 @@ are specified. any of these dependencies will trigger a rebuild of the current target, but `deps` do not appear in `ins` or Ninja `$in`. +- `dependency_order`: An optional `parallel` or `serial` policy for the direct + `deps` list on an action or target. It defaults to `parallel`; `serial` + preserves declaration order without changing the freshness class of `deps`. + - `order_only_deps`: An optional list of prerequisite target names or paths that must be built before this target, but whose modification does not trigger a rebuild of this target. This maps directly to Ninja's order-only @@ -404,6 +408,7 @@ The cleaner model is: - `sources` contribute to `ins` / `$in`. - `deps` affect ordering and rebuild decisions, but do not appear in `ins`. - `order_only_deps` affect ordering only. +- `dependency_order` changes only the scheduling policy for direct `deps`. - `vars`: An optional mapping of local variables. These variables override any global variables defined in the top-level `vars` section for the scope of @@ -2097,6 +2102,22 @@ structures to the Ninja file syntax. build my_app: link foo.o bar.o | lib_dependency.a ``` + A `BuildEdge` whose `dependency_order` is `serial` and has more than one + implicit dependency is an exception to this direct rendering. The generator + lowers it into staged phony gates, with one content-addressed Ninja dyndep + sidecar per dependency. A gate can reveal exactly one real dependency; the + edge producing the next sidecar depends on the preceding gate. This makes + each later dependency unavailable to Ninja until the previous one succeeds, + while preserving one Ninja scheduler and its shared-work memoization. + + The generated result is a bundle, not merely a string: the main Ninja text + and its `.netsuke/dyndep` sidecars must be materialized relative to the + effective Ninja working directory before the file can run. The main file + declares `ninja_required_version = 1.10` only when it contains such staged + serial ordering. `.netsuke/serial` and `.netsuke/dyndep` are reserved for + generated state. `serial` applies only to direct implicit dependencies; it + does not delay an independently reachable node elsewhere in the graph. + 4\. **Write Defaults:** Finally, write the `default` statement, listing all paths from `graph.default_targets`. @@ -2132,6 +2153,11 @@ representation portable. optional key-value pairs or flags, keeping the generator easy to scan. - Integration tests snapshot the generated Ninja file with `insta` and execute the Ninja binary to validate structure and no-op behaviour. + Serial-ordering tests additionally use real Ninja to prove declaration + order, failure short-circuiting, shared-work reuse, and unrelated-branch + concurrency. [ADR-010](adr-010-use-ninja-dyndep-for-serial-dependency-ordering.md) + records why staged dyndep is used instead of order-only gates, pools, or + recursive Ninja invocations. ## Section 6: Process Management and Secure Execution diff --git a/docs/repository-layout.md b/docs/repository-layout.md index 4c90317f5..68bf63ef9 100644 --- a/docs/repository-layout.md +++ b/docs/repository-layout.md @@ -29,6 +29,7 @@ output and some leaf files so the long-lived structure remains visible. │ ├── ir/ │ ├── localization/ │ ├── manifest/ +│ ├── ninja_gen/ │ ├── runner/ │ ├── snapshots/ │ └── stdlib/ @@ -85,6 +86,8 @@ output and some leaf files so the long-lived structure remains visible. support. - `src/manifest/`: Manifest parsing, expansion, rendering, diagnostics, and manifest-specific tests. +- `src/ninja_gen/`: Ninja rendering, including staged dyndep generation for + serial manifest dependencies. - `src/runner/`: Process execution, path handling, runner errors, and runtime command orchestration. - `src/snapshots/`: Checked-in `insta` snapshots for source-level snapshot @@ -134,3 +137,9 @@ Place feature files in `tests/features/` unless the behaviour depends on Unix-specific platform contracts, in which case use `tests/features_unix/`. Place generated or approved snapshot files under the existing `src/snapshots/` or `tests/snapshots/` hierarchy that matches the test owner. + +Netsuke runtime state belongs under `.netsuke/` in the effective working +directory, never in the repository layout itself. In particular, +`.netsuke/dyndep` contains immutable content-addressed sidecars for serial +dependencies and `.netsuke/serial` is a reserved generated-gate namespace; +manifest outputs must not claim either path. diff --git a/docs/roadmap.md b/docs/roadmap.md index fafa7f7b5..37d85f262 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -175,6 +175,11 @@ and agents. ordering and rebuild decisions without appearing in recipe arguments. - [x] Align cycle detection, generated Ninja output, and user-facing dependency documentation. + - [x] Add `dependency_order: serial` for direct action and target `deps`. + Staged Ninja dyndep lowering preserves declaration order, failure + short-circuiting, shared-work reuse, and unrelated-branch concurrency; + [ADR-010](adr-010-use-ninja-dyndep-for-serial-dependency-ordering.md) + records the path-scoped guarantee and generated-state contract. - [x] 3.14.4. Add `command_available(name, **kwargs)` as a non-throwing executable probe. Depends on archived task `3.5.1`. See [executable discovery](netsuke-design.md#executable-discovery-filter-which). diff --git a/docs/users-guide.md b/docs/users-guide.md index 42955b857..4f5f70ba0 100644 --- a/docs/users-guide.md +++ b/docs/users-guide.md @@ -386,6 +386,9 @@ A target supports these fields: recipe arguments. Declare them on each target; reusable rules reject `deps`. The planned rule-level `deps_from` contract is not implemented in v0.1.0-beta1. +- `dependency_order`: scheduling policy for the `deps` list. `parallel` is the + default; `serial` runs a list with more than one dependency in declaration + order. - `order_only_deps`: ordering dependencies. Their changes do not rebuild the dependent target. - `vars`: values that override global variables for this target. The `env` @@ -408,6 +411,64 @@ v0.1.0-beta1. Cycle detection follows `sources` and `deps`. Order-only dependencies enforce ordering but do not participate in cycle detection. + +### Run direct dependencies serially + +Actions and targets both accept `dependency_order`. Omit it, or set it to +`parallel`, to retain Ninja's ordinary concurrent scheduling. Set it to +`serial` when the direct `deps` list is an ordered workflow: + + + +```yaml +netsuke_version: "1.0.0" + +actions: + - name: check-fmt + command: "echo checking format" + - name: lint + command: "echo linting" + - name: test + command: "echo testing" + - name: all + command: ":" + dependency_order: serial + deps: + - check-fmt + - lint + - test + +targets: + - name: release-notes + command: "echo preparing release notes" + - name: release + command: "./package-release" + dependency_order: serial + deps: + - check-fmt + - test + - release-notes +``` + +For a serial list, Netsuke starts each direct dependency only after the +preceding one succeeds. If an earlier dependency fails, later dependencies in +that list do not start through the serial path. Repeated or shared dependencies +are still owned by the one Ninja invocation and execute at most once. + +Serial ordering applies only to the direct `deps` list. It does not serialize +`sources`, `order_only_deps`, or unrelated work. An independently requested or +otherwise reachable later dependency can still start through that separate +path; use a dedicated aggregate action when the whole workflow must share the +same ordered entry point. + +Netsuke uses Ninja's `dyndep` support for serial lists with two or more +dependencies, and generated builds containing one require Ninja 1.10 or newer. +`netsuke generate`, `build`, and `clean` materialize the generated sidecars +under `.netsuke/dyndep` in the effective working directory. Those +content-addressed files are reusable state and `clean` does not remove them. +Do not define targets beneath `.netsuke/serial` or `.netsuke/dyndep`; Netsuke +reserves both paths for this feature. + ## Use Jinja safely Jinja expressions are allowed in renderable string fields, including variables, @@ -853,7 +914,9 @@ textual outline and a `