Skip to content
14 changes: 14 additions & 0 deletions docs/developers-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,20 @@ Netsuke uses a mixed strategy:
- Behavioural step definitions and fixtures live in `tests/bdd/`.
- Behavioural test discovery is defined in `tests/bdd_tests.rs`.

## IR dependency classes

`src/ir/from_manifest.rs` lowers manifest `sources` into
`BuildEdge.inputs`, manifest `deps` into `BuildEdge.implicit_deps`, and
manifest `order_only_deps` 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.

`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.

## Behavioural testing strategy

Behavioural tests run through `cargo test` using `rstest-bdd`, not a bespoke
Expand Down
854 changes: 854 additions & 0 deletions docs/execplans/3-14-3-lower-target-and-action-deps.md

Large diffs are not rendered by default.

16 changes: 6 additions & 10 deletions docs/formal-verification-methods-in-netsuke.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,16 +236,12 @@ that affects manifest authoring. The project documentation should state whether:

### Cycle-participation contract

`BuildEdge` records explicit inputs, explicit outputs, implicit outputs, and
order-only dependencies, but the current cycle detector walks `edge.inputs`
only.[^5][^7] This contract should be documented in the user guide under the
dependency and build-graph semantics chapter, as it defines the behaviour users
observe when circular dependencies are detected. Before proofs are written, the
intended scope of cycle detection should be recorded explicitly:

- explicit inputs only,
- explicit inputs plus order-only dependencies, or
- explicit, implicit, and order-only dependencies together.
`BuildEdge` records explicit inputs, implicit dependencies, explicit outputs,
implicit outputs, and order-only dependencies.[^5][^7] Cycle detection walks
explicit inputs and implicit dependencies: in manifest terms, `sources` and
`deps` participate. Order-only dependencies do not participate, because they
only enforce build ordering and do not affect rebuild freshness. The user guide
documents the same contract in its target dependency-field section.

### Determinism contract

Expand Down
4 changes: 2 additions & 2 deletions docs/netsuke-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -1747,11 +1747,11 @@ pub struct BuildEdge {
/// The unique identifier of the Action used for this edge.
pub action_id: String,

/// Explicit inputs that, when changed, trigger a rebuild.
/// Explicit recipe inputs that, when changed, trigger a rebuild.
pub inputs: Vec<Utf8PathBuf>,

/// Implicit dependencies that must be built first and trigger rebuilds.
/// Maps to Ninja's '|' syntax and remains separate from `$in`.
/// Maps to Ninja's '|' syntax and remains separate from `$in` / `{{ ins }}`.
pub implicit_deps: Vec<Utf8PathBuf>,

/// Outputs explicitly generated by the command.
Expand Down
8 changes: 4 additions & 4 deletions docs/roadmap.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,14 +159,14 @@ and agents.
expansion.
- [x] Support complementary branches such as `when: command_available(...)`
and `when: not command_available(...)`.
- [ ] 3.14.3. Lower target and action `deps` into implicit IR and Ninja
- [x] 3.14.3. Lower target and action `deps` into implicit IR and Ninja
dependency edges. Requires 1.2.2 and 1.3.2. See
[netsuke-design.md §§2.4 and 5.3](netsuke-design.md).
- [ ] Keep `sources` in the explicit recipe-input class used for `ins` and
- [x] Keep `sources` in the explicit recipe-input class used for `ins` and
`$in`.
- [ ] Add a separate implicit dependency class for `deps` so they affect
- [x] Add a separate implicit dependency class for `deps` so they affect
ordering and rebuild decisions without appearing in recipe arguments.
- [ ] Align cycle detection, generated Ninja output, and user-facing
- [x] Align cycle detection, generated Ninja output, and user-facing
dependency documentation.
- [ ] 3.14.4. Add `command_available(name, **kwargs)` as a non-throwing
executable probe. Requires 3.5.1. See
Expand Down
14 changes: 10 additions & 4 deletions docs/users-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -231,15 +231,21 @@ targets:
- `recipe`: How to build the target. Defined by one of `rule`, `command`, or
`script` (mutually exclusive).

- `sources`: Input file path(s) (`StringOrList`). If a source matches another
target's `name`, an implicit dependency is created.
- `sources`: Input file path(s) (`StringOrList`). Sources are explicit recipe
inputs: they are passed to `$in` and `{{ ins }}` and trigger rebuilds when
changed.

- `deps` (Optional): Explicit target dependencies (`StringOrList`). Changes
trigger rebuilds.
- `deps` (Optional): Implicit target dependencies (`StringOrList`). Changes
trigger rebuilds, but these paths are not passed to `$in` or `{{ ins }}`.
Maps to Ninja `|`.

- `order_only_deps` (Optional): Dependencies that must run first but whose
changes don't trigger rebuilds (`StringOrList`). Maps to Ninja `||`.

Cycle detection traverses `sources` and `deps`, because both classes affect
the build graph and rebuild freshness. `order_only_deps` only enforce build
ordering and do not participate in Netsuke's cycle detection.

- `vars` (Optional): Target-specific variables that override global `vars`.

- `phony` (Optional, default `false`): Treat target as logical, not a file.
Expand Down
7 changes: 5 additions & 2 deletions src/ir/cmd_interpolate.rs
Original file line number Diff line number Diff line change
Expand Up @@ -137,10 +137,13 @@ fn find_substitution<'a>(
}

fn substitute(template: &str, ins: &[String], outs: &[String]) -> String {
let chars: Vec<char> = template.chars().collect();
let ins_joined = ins.join(" ");
let outs_joined = outs.join(" ");
let mut out = String::with_capacity(template.len());
let placeholder_template = template
.replace("{{ ins }}", &ins_joined)
.replace("{{ outs }}", &outs_joined);
let chars: Vec<char> = placeholder_template.chars().collect();
let mut out = String::with_capacity(placeholder_template.len());
Comment on lines +142 to +146
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Template-level replacement of {{ ins }}/{{ outs }} ignores backtick-protected regions, unlike the rest of the interpolation logic.

Because the .replace("{{ ins }}", …).replace("{{ outs }}", …) runs before the backtick-aware scan, these placeholders now get expanded even inside backticks, unlike other substitutions that respect backtick-delimited “no interpolation” regions.

To preserve the existing escape semantics, consider handling {{ ins }}/{{ outs }} inside the find_substitution/scanning loop (or otherwise applying the same backtick rules to them) rather than doing a global replace first.

Comment on lines +142 to +146
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Consider adding a comment explaining the preprocessing stage.

The substitution now operates in two stages: first replacing {{ ins }} and {{ outs }} placeholders (lines 142-144), then performing the existing backtick-aware $in/$out substitution. Add an inline comment clarifying this two-stage pipeline and noting that the {{ }} placeholders are replaced unconditionally before backtick detection.

📝 Suggested inline comment
 fn substitute(template: &str, ins: &[String], outs: &[String]) -> String {
     let ins_joined = ins.join(" ");
     let outs_joined = outs.join(" ");
+    // Stage 1: Replace {{ ins }} and {{ outs }} placeholders unconditionally.
+    // Stage 2 (below) performs backtick-aware $in/$out substitution.
     let placeholder_template = template
         .replace("{{ ins }}", &ins_joined)
         .replace("{{ outs }}", &outs_joined);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/ir/cmd_interpolate.rs` around lines 142 - 146, Add an inline comment in
the substitute function clarifying the two-stage preprocessing: before building
chars/out, note that Stage 1 unconditionally replaces the literal "{{ ins }}"
and "{{ outs }}" with joined values (placeholder_template), and Stage 2 (the
following logic) performs backtick-aware $in/$out substitution; place this
comment immediately above the placeholder_template assignment (near the
ins_joined/outs_joined code) so readers understand the pipeline and ordering.

let mut in_backticks = false;
let mut i = 0;
while let Some(&ch) = chars.get(i) {
Expand Down
101 changes: 89 additions & 12 deletions src/ir/cycle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ impl CycleDetector<'_> {
.targets
.get(&node)
.into_iter()
.flat_map(|edge| edge.inputs.iter())
.flat_map(|edge| edge.inputs.iter().chain(&edge.implicit_deps))
.find_map(|dep| self.visit_dependency(&node, dep))
{
return Some(cycle);
Expand Down Expand Up @@ -162,10 +162,11 @@ mod tests {
Utf8PathBuf::from(name)
}

fn build_edge(inputs: &[&str], output: &str) -> BuildEdge {
fn build_edge(inputs: &[&str], implicit_deps: &[&str], output: &str) -> BuildEdge {
BuildEdge {
action_id: "id".into(),
inputs: inputs.iter().map(|name| path(name)).collect(),
implicit_deps: implicit_deps.iter().map(|name| path(name)).collect(),
explicit_outputs: vec![path(output)],
implicit_outputs: Vec::new(),
order_only_deps: Vec::new(),
Expand All @@ -174,10 +175,51 @@ mod tests {
}
}

fn assert_missing_deps(
targets: &HashMap<Utf8PathBuf, BuildEdge>,
expected: &[(Utf8PathBuf, Utf8PathBuf)],
) {
let mut detector = CycleDetector::new(targets);
assert!(detector.visit(path("a")).is_none());
assert_eq!(detector.missing_dependencies(), expected);
}

fn next_cycle_index(index: usize, cycle_len: usize) -> usize {
if index + 1 == cycle_len { 0 } else { index + 1 }
}

fn insert_cycle_edge(
targets: &mut HashMap<Utf8PathBuf, BuildEdge>,
index: usize,
cycle_len: usize,
implicit_index: usize,
) {
let output = format!("n{index}");
let dep = format!("n{}", next_cycle_index(index, cycle_len));
let edge = if index == implicit_index {
build_edge(&[], &[&dep], &output)
} else {
build_edge(&[&dep], &[], &output)
};
targets.insert(output.into(), edge);
}

fn assert_bounded_cycle_detected(cycle_len: usize, implicit_index: usize) {
let mut targets = HashMap::new();
for index in 0..cycle_len {
insert_cycle_edge(&mut targets, index, cycle_len, implicit_index);
}

assert!(
CycleDetector::find_cycle(&targets).is_some(),
"expected cycle with length {cycle_len} and implicit edge at {implicit_index}",
);
}

#[test]
fn cycle_detector_detects_self_edge_cycle() {
let mut targets = HashMap::new();
targets.insert(path("a"), build_edge(&["a"], "a"));
targets.insert(path("a"), build_edge(&["a"], &[], "a"));

let cycle = CycleDetector::find_cycle(&targets).expect("cycle");
assert_eq!(cycle, vec![path("a"), path("a")]);
Expand All @@ -188,8 +230,8 @@ mod tests {
let mut targets = HashMap::new();
let a = path("a");
let b = path("b");
targets.insert(a.clone(), build_edge(&["b"], "a"));
targets.insert(b.clone(), build_edge(&[], "b"));
targets.insert(a.clone(), build_edge(&["b"], &[], "a"));
targets.insert(b.clone(), build_edge(&[], &[], "b"));

let mut detector = CycleDetector::new(&targets);
assert!(detector.visit(a.clone()).is_none());
Expand All @@ -204,24 +246,59 @@ mod tests {
#[test]
fn cycle_detector_records_missing_dependencies() {
let mut targets = HashMap::new();
targets.insert(path("a"), build_edge(&["b"], "a"));
targets.insert(path("a"), build_edge(&["b"], &[], "a"));
assert_missing_deps(&targets, &[(path("a"), path("b"))]);
}

let mut detector = CycleDetector::new(&targets);
assert!(detector.visit(path("a")).is_none());
#[test]
fn find_cycle_identifies_cycle() {
let mut targets = HashMap::new();
targets.insert(path("a"), build_edge(&["b"], &[], "a"));
targets.insert(path("b"), build_edge(&["a"], &[], "b"));

assert_eq!(detector.missing_dependencies(), &[(path("a"), path("b"))],);
let cycle = CycleDetector::find_cycle(&targets).expect("cycle");
assert_eq!(cycle, vec![path("a"), path("b"), path("a")]);
}

#[test]
fn find_cycle_identifies_cycle() {
fn find_cycle_identifies_implicit_dependency_cycle() {
let mut targets = HashMap::new();
targets.insert(path("a"), build_edge(&["b"], "a"));
targets.insert(path("b"), build_edge(&["a"], "b"));
targets.insert(path("a"), build_edge(&[], &["b"], "a"));
targets.insert(path("b"), build_edge(&[], &["a"], "b"));

let cycle = CycleDetector::find_cycle(&targets).expect("cycle");
assert_eq!(cycle, vec![path("a"), path("b"), path("a")]);
}

#[test]
fn find_cycle_identifies_mixed_input_and_implicit_dependency_cycle() {
let mut targets = HashMap::new();
targets.insert(path("a"), build_edge(&["b"], &[], "a"));
targets.insert(path("b"), build_edge(&[], &["c"], "b"));
targets.insert(path("c"), build_edge(&["a"], &[], "c"));

let cycle = CycleDetector::find_cycle(&targets).expect("cycle");
assert_eq!(cycle, vec![path("a"), path("b"), path("c"), path("a")]);
}

#[test]
fn cycle_detector_records_missing_implicit_dependencies() {
let mut targets = HashMap::new();
targets.insert(path("a"), build_edge(&["b"], &["missing"], "a"));
targets.insert(path("b"), build_edge(&[], &[], "b"));
assert_missing_deps(&targets, &[(path("a"), path("missing"))]);
}

#[test]
fn bounded_cycles_through_inputs_or_implicit_deps_are_detected() {
let cases = (2..=5).flat_map(|cycle_len| {
(0..cycle_len).map(move |implicit_index| (cycle_len, implicit_index))
});
for (cycle_len, implicit_index) in cases {
assert_bounded_cycle_detected(cycle_len, implicit_index);
}
}

#[test]
fn canonicalize_cycle_rotates_smallest_node() {
let cycle = vec![path("c"), path("a"), path("b"), path("c")];
Expand Down
2 changes: 2 additions & 0 deletions src/ir/from_manifest.rs
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ impl BuildGraph {
for target in manifest.actions.iter().chain(&manifest.targets) {
let outputs = to_paths(&target.name);
let inputs = to_paths(&target.sources);
let implicit_deps = to_paths(&target.deps);
let target_name = get_target_display_name(&outputs);
let action_id = match &target.recipe {
Recipe::Rule { rule } => {
Expand Down Expand Up @@ -90,6 +91,7 @@ impl BuildGraph {
let edge = BuildEdge {
action_id,
inputs: inputs.clone(),
implicit_deps,
explicit_outputs: outputs.clone(),
implicit_outputs: Vec::new(),
order_only_deps: to_paths(&target.order_only_deps),
Expand Down
2 changes: 2 additions & 0 deletions src/ir/graph.rs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ pub struct BuildEdge {
pub action_id: String,
/// Explicit inputs that trigger a rebuild when changed.
pub inputs: Vec<Utf8PathBuf>,
/// Implicit dependencies that trigger a rebuild without entering recipes.
pub implicit_deps: Vec<Utf8PathBuf>,
/// Outputs explicitly generated by the command.
pub explicit_outputs: Vec<Utf8PathBuf>,
/// Outputs implicitly generated by the command (Ninja `|`).
Expand Down
20 changes: 18 additions & 2 deletions src/manifest/render.rs
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ fn render_rule(rule: &mut crate::ast::Rule, env: &Environment, vars: &Vars) -> R
render_string_or_list(&mut rule.deps, env, vars)?;
match &mut rule.recipe {
Recipe::Command { command } => {
*command = render_str_with(env, command, vars, || "render rule command".into())?;
*command = render_recipe_str_with(env, command, vars, || "render rule command".into())?;
}
Recipe::Script { script } => {
*script = render_str_with(env, script, vars, || "render rule script".into())?;
Expand All @@ -52,7 +52,7 @@ fn render_target(target: &mut Target, env: &Environment) -> Result<()> {
render_string_or_list(&mut target.order_only_deps, env, &target.vars)?;
match &mut target.recipe {
Recipe::Command { command } => {
*command = render_str_with(env, command, &target.vars, || {
*command = render_recipe_str_with(env, command, &target.vars, || {
"render target command".into()
})?;
}
Expand Down Expand Up @@ -98,6 +98,22 @@ fn render_str_with(
env.render_str(tpl, ctx).with_context(what)
}

fn render_recipe_str_with(
env: &Environment,
tpl: &str,
ctx: &Vars,
what: impl FnOnce() -> String,
) -> Result<String> {
let mut recipe_ctx = ctx.clone();
recipe_ctx
.entry("ins".into())
.or_insert_with(|| ManifestValue::String("{{ ins }}".into()));
Comment on lines +101 to +110
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

question: Defaulting ins/outs to "{{ ins }}"/"{{ outs }}" makes it hard to represent those literals in recipes.

Because ins/outs are injected as the literal strings "{{ ins }}"/"{{ outs }}" and then substituted, recipes have no way to emit those exact literals in their output, even when using constructs like {% raw %}. If supporting literal {{ ins }}/{{ outs }} is needed, consider a different internal marker or a way to opt out/override this behavior so there is an escape hatch.

recipe_ctx
.entry("outs".into())
.or_insert_with(|| ManifestValue::String("{{ outs }}".into()));
render_str_with(env, tpl, &recipe_ctx, what)
}

Comment on lines +101 to +116
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick | 🔵 Trivial | ⚡ Quick win

Document the helper's ins/outs injection behaviour.

Add a doc comment explaining that render_recipe_str_with ensures ins and outs template variables are always available by injecting default placeholder strings ("{{ ins }}" and "{{ outs }}") when absent, allowing recipe commands to reference these variables during MiniJinja rendering before subsequent Ninja substitution.

📝 Suggested doc comment
+/// Render a recipe command template with guaranteed `ins` and `outs` variables.
+///
+/// Clones the provided context and injects default `"{{ ins }}"` and `"{{ outs }}"`
+/// placeholder strings if not already present, ensuring recipe commands can always
+/// reference these variables during MiniJinja rendering. The placeholders are later
+/// substituted with actual paths during command interpolation.
 fn render_recipe_str_with(
     env: &Environment,
     tpl: &str,
     ctx: &Vars,
     what: impl FnOnce() -> String,
 ) -> Result<String> {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/manifest/render.rs` around lines 101 - 116, Add a doc comment to
render_recipe_str_with describing that it clones the supplied context (Vars) and
guarantees template variables "ins" and "outs" by injecting default placeholder
strings "{{ ins }}" and "{{ outs }}" when they are absent, so MiniJinja
rendering can reference them before later Ninja substitution; place the comment
immediately above the render_recipe_str_with signature and mention Environment,
Vars, and that render_str_with is called to perform the rendering.

#[cfg(test)]
mod tests {
use super::*;
Expand Down
7 changes: 7 additions & 0 deletions src/ninja_gen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ macro_rules! write_flag {
/// });
/// graph.targets.insert(Utf8PathBuf::from("out"), BuildEdge {
/// action_id: "a".into(), inputs: Vec::new(),
/// implicit_deps: Vec::new(),
/// explicit_outputs: vec![Utf8PathBuf::from("out")],
/// implicit_outputs: Vec::new(), order_only_deps: Vec::new(),
/// phony: false, always: false
Expand Down Expand Up @@ -112,6 +113,7 @@ pub fn generate(graph: &BuildGraph) -> Result<String, NinjaGenError> {
/// });
/// graph.targets.insert(Utf8PathBuf::from("out"), BuildEdge {
/// action_id: "a".into(), inputs: Vec::new(),
/// implicit_deps: Vec::new(),
/// explicit_outputs: vec![Utf8PathBuf::from("out")],
/// implicit_outputs: Vec::new(), order_only_deps: Vec::new(),
/// phony: false, always: false
Expand Down Expand Up @@ -288,6 +290,9 @@ impl Display for DisplayEdge<'_> {
if !self.edge.inputs.is_empty() {
write!(f, " {}", join(&self.edge.inputs))?;
}
if !self.edge.implicit_deps.is_empty() {
write!(f, " | {}", join(&self.edge.implicit_deps))?;
}
if !self.edge.order_only_deps.is_empty() {
write!(f, " || {}", join(&self.edge.order_only_deps))?;
}
Expand Down Expand Up @@ -319,6 +324,7 @@ mod tests {
let edge = BuildEdge {
action_id: "a".into(),
inputs: vec![Utf8PathBuf::from("in")],
implicit_deps: Vec::new(),
explicit_outputs: vec![Utf8PathBuf::from("out")],
implicit_outputs: Vec::new(),
order_only_deps: Vec::new(),
Expand Down Expand Up @@ -360,6 +366,7 @@ mod tests {
let edge = BuildEdge {
action_id: "a".into(),
inputs: Vec::new(),
implicit_deps: Vec::new(),
explicit_outputs: vec![Utf8PathBuf::from("out")],
implicit_outputs: Vec::new(),
order_only_deps: Vec::new(),
Expand Down
Loading
Loading