- Forthcoming
- Property Testing
Property Testing
A fixture proves that one manifest compiles to one build script. A property proves something about every build script a family of manifests can produce: that no action leaks the ambient environment, that declaration order never changes the output, that an unrelated target leaves existing actions alone. RFC 0012 proposes that step for the Netsukefile testing framework, without adding a scripting language to do it.
Status on 19 October 2026
- Not in v0.1.0-beta4, and not implemented. The released CLI has no
testcommand, and nothing on Netsuke's main branch implementsforall,mutations,for_all_actions, orresult.actions. One seam the design needs, a constructor that pins the recipe shell, already exists for other reasons. - Proposed. RFC 0012, created 2 September 2026, is a proposed extension to RFC 0007, which is itself still a proposal. Syntax on this page is the RFC's and may change.
- Sequenced after the testing framework. Roadmap phase 10 depends on the phase 7 result views and case supervisor, none of which has landed. Every phase 10 task is unticked.
- No release named. Neither the RFC nor the roadmap names a target release. This preview describes a design, not a date.
Why properties
A Netsukefile is a build-script generator. Its bugs rarely live in the one configuration a fixture pins; they live in the flag ordering nobody tried, the target name with an awkward character, the declaration someone moved. RFC 0012 lets authors state invariants over families of manifests, with the generated build script — principally its command invocations and environment constructions — as the subject under test.
It extends the framework
Everything here runs inside netsuke test as the testing framework preview describes it: the same YAML dialect, the same given/when/then steps, the same sandboxed child per case, the same FAIL versus ERROR reporting. Property testing adds a view, a block, a vocabulary, and a lint. Without the framework it has nothing to extend.
Raw Ninja is the wrong subject
Recipe text depends on the host shell, and per-action environment maps never appear in build.ninja at all. The RFC rejects properties over Ninja text because they end up either flaky across hosts or weakened to the point of vacuity.
Closed on purpose
No user-defined predicate functions, strategies, shrinkers, or callbacks. Assertions are MiniJinja expressions the framework already evaluates; generators and mutations come from fixed vocabularies. The RFC rejects a general property-testing DSL because it would reopen the scripting surface RFC 0007 closed.
Not the same as Netsuke's own property tests. Roadmap §4.3 plans Proptest suites that check Netsuke's compiler — deterministic Ninja emission, expansion invariants, render stability — inside its Rust test tree. That is Netsuke testing itself. This preview is about authors testing their Netsukefiles. RFC 0012 leaves the internal suites exactly as they are.
Observing actions: result.actions
The framework already proposes three result views: result.manifest, result.graph, and
result.ninja. RFC 0012 adds a fourth, available after build_graph, built from the IR with the recipe
shell pinned so identical manifests project identically on every host. The view is additive and exposes no internal IR types.
Planned under roadmap 10.1.
| Field | Meaning (RFC 0012 Table 1) |
|---|---|
| target | The build edge's primary output target name. |
| rule | The owning rule name. |
| argv | The tokenized command, post-interpolation, pre-quoting. |
| env | The constructed environment map for the invocation. |
| cwd | The working directory, when the action declares one. |
| inputs | The resolved $in bindings. |
| outputs | The resolved $out bindings. |
| pool, depfile, dyndep | The assigned pool, depfile path template, and dyndep binding, when present. |
Tokens, not quoting
argv is deliberately the shell-independent token list. Whether a token is quoted correctly for sh or PowerShell is the escaping seam's problem, tested inside Netsuke, not the author's. Helpers mirror the graph view: action(target), actions_for_rule(name), and has_action(target).
A canonical order
Actions sort by target, then rule, then the RFC 8785 canonical JSON of the remaining fields — the canonical value contract of RFC 0006 §6.7. Declaration order and IR iteration order cannot change the view, so a quantified result reads the same on every run.
Two quantifiers join the then vocabulary: for_all_actions evaluates each listed expression once per
action, binding action; for_all_targets does the same over the graph view, binding target.
A failure reports the binding that falsified it with the substituted actual values. Assertions read env in full;
every copy that leaves the runner — failure reports, diagnostics, rendered views, regression files — carries opaque
key tokens and a fixed <redacted> marker instead. Redaction happens only on the way out, so it can never turn
a FAIL into an ERROR.
Bounded generation: the forall block
A case may declare forall, a mapping from binding names to domain constructors. The runner expands the domains into a
finite family and runs the case's steps once per drawn tuple; drawn values are available wherever let
bindings are. The case below draws a target name and a flag ordering, builds the graph, and asserts that no action's environment
strays beyond the declared variables and no action lists a source twice.
Planned under roadmap 10.2.
netsuke_test_version: "1.1"
test_command_env_is_closed_over_declared_vars:
description: Generated invocations never leak ambient environment.
forall:
target_name: { path_fragment: { max_len: 12 } }
flag_order: { permutations_of: ["-O2", "-g", "-Wall"] }
steps:
- given:
let:
declared_env: []
name: "{{ target_name }}"
flags: "{{ flag_order }}"
- when: build_graph
- then:
for_all_actions:
- "action.env | keys | reject('in', declared_env) | length == 0"
- "action.inputs | unique | length == action.inputs | length"
| Constructor | Draws | Expansion |
|---|---|---|
| one_of | One member of a finite set | Exhaustive |
| permutations_of | One ordering of the listed items | Exhaustive up to the ceiling |
| int | An integer from an inclusive range | Exhaustive up to the ceiling |
| path_fragment | A sandbox-safe relative path token | Sampled |
| list_of | A bounded list over a child domain | Sampled |
When the Cartesian product of the declared domains fits under the expansion ceiling, every tuple is checked. Above it, the runner
samples with a generator seeded with the fixed default seed 0, so the default run is deterministic. The ceiling, the
sample count, and a per-case deadline are enforced limits. Internally the runner reuses Proptest's generator and regression-file
conventions; no Proptest type appears in the dialect.
Metamorphic mutations
The most valuable properties of a build-script generator compare two runs rather than inspecting one. These need no predicates
from the author, so they ship as a closed mutations vocabulary: the runner derives a mutated manifest, runs the
plan-mode pipeline on both, and asserts the named relation. The case below asserts that the generated script ignores
declaration order and that adding an unrelated target leaves existing actions alone.
Planned under roadmap 10.3.
netsuke_test_version: "1.1"
test_generation_is_order_invariant:
description: Declaration order never changes the generated script.
subject:
manifest: fixtures/webapp/Netsukefile
mutations:
- permute_declarations
- insert_irrelevant_target
| Mutation | Relation asserted |
|---|---|
| permute_declarations | Actions and Ninja output are identical. |
| insert_irrelevant_target | Pre-existing actions are unchanged. |
| rename_target | Actions are isomorphic under the renaming. |
| remove_declared_input | The declared expect_failure is raised. |
Between them the four cover determinism, order invariance, locality, and rename isomorphism. Mutations compose with
forall: each drawn tuple runs the base and the mutated pipeline. Both run inside the framework's killable child
process, so a mutant that diverges or never terminates cannot wedge the runner.
Coverage and replay
Shrink, persist, replay
On failure the runner delta-reduces the drawn tuple towards domain minima, reports the smallest falsifying binding set it finds, and names both the seed and that tuple. It then persists seed and tuple in a regression file under the test tree, and later runs replay it before generating anything fresh. --seed <n> selects a reported seed, but a seed alone is not a replay: without the persisted tuple inputs the run samples afresh from that seed.
The coverage completeness lint
When a rule interpolates a command or constructs an environment and no test in the suite covers one of its actions with a quantified or metamorphic assertion, the run names the rule. The report is advisory by default; --strict-coverage makes it a failure. New invocation shapes opt into the property contract deliberately instead of drifting past it. The lint arrives last in the phase.
$ netsuke test --seed <n>
$ netsuke test --strict-coverage
Everything else on the command line — filters, --tag, --timeout, --json, the exit
codes — is the framework's, described under the command
in its preview. The RFC does not specify how the seed, tuple, or falsifying binding appear in JSON output, and does not show a
rendered property failure, so neither is sketched here.
From the shallow end
Property testing is opt-in by dialect version. Existing netsuke_test_version: "1.0" files parse and run unchanged;
only a file that uses forall, mutations, or result.actions declares "1.1".
The shallow end is therefore the framework's own quick start: one file beside the manifest, one case, plain netsuke test.
netsuke_test_version: "1.0"
test_hello_target_is_generated:
steps:
- when: generate_ninja
then:
- result.ok
- result.graph.has_target("build/hello.txt")
-
1. A 1.0 test file
Fixed fixtures and assertions over the manifest, graph, and Ninja views. No properties, no version bump. See the testing framework's shallow end.
-
2.
mutations, with no expressionsDeclare
"1.1", point a case at an existing manifest, and listpermute_declarations. The relation is built in, so the first property needs no predicate at all — the RFC's example is five lines of intent. -
3.
forallover small finite setsone_of,permutations_of, and smallintranges enumerate exhaustively under the ceiling, so every combination is checked rather than sampled, withfor_all_actionsassertions over each. Sampled domains such aspath_fragmentandlist_ofcome after, with seeds and replay. -
4. Read the coverage lint
The advisory report names each rule whose command or environment no property covers. It is the signpost to the deep end: a list of the next properties worth writing.
-
5. Gate on
--strict-coverageOnce the list is empty, make it stay empty: an uncovered rule fails the run, so a new invocation shape cannot land without a property.
No tutorial exists yet. Roadmap 10.3.4 plans the users' guide testing chapter for the property dialect, after the dialect has been dogfooded on Netsuke's own example manifests.
Confidence
Every guarantee below is designed, not shipped. The phase's own success criterion is that the RFC's worked examples run green deterministically on a machine with no compiler and no network, and that a seeded failure replays identically from its reported seed and tuple inputs.
Plan mode only
Properties observe plan-mode projections and never execute the build: no network, no ambient environment, no build execution. Fuzzing Ninja, the shell, or spawned processes is an explicit non-goal.
Deterministic by default
No ambient clock, the fixed default seed 0, and every report naming the seed in effect. Roadmap 10.2.2 requires repeated default runs to be byte-identical; the pinned shell makes result.actions identical across hosts.
Bounded and killable
The ceiling, sample count, and per-case deadline are enforced limits, and each case runs in a child the runner can kill. Unbounded generation and coverage-guided exploration are non-goals.
Proven to catch something
Roadmap 10.3.1 accepts each mutation only once a seeded suite shows it detecting a deliberately introduced generator defect. And a 1.0 runner fails closed on a 1.1 file with its unsupported-version diagnostic rather than misparsing it.
Open questions in the RFC.
- The default expansion ceiling and sample count. The RFC suggests something between 16 and 24, set by measuring realistic suites; roadmap 10.3.3 records the measurement.
- Whether
result.actionsshould land early, inside the framework's phase 7 result-view work, so example-based tests can use it before properties arrive. The roadmap currently keeps it in phase 10. - Whether delta reduction over the drawn tuple is enough, or whether sampled list domains need element-wise shrinking. Limited shrinking is a stated non-goal, not an oversight.
Accessibility & localization
Inherited output modes
Property failures are netsuke test reports, so they follow its display policy flags — --color, --emoji, --progress, --accessibility — and its stream-purity contract: one JSON document per completed run under --json. See the framework's accessibility notes and the CLI reference.
Localized like the rest
The framework design localizes every human-facing string like the rest of the CLI, and roadmap 7.6.2 plans Fluent keys for report lines, diagnostics, and warnings. Property reports are no exception: every human-readable message Netsuke emits is localized by default, so a falsified property, a replay hint, or a coverage warning reads in the user’s locale, while seeds, rule names, and drawn values stay exactly as generated.
Redacted by design
Environment keys and values never leave the runner in clear: reports, diagnostics, and persisted regression files carry opaque key tokens and <redacted>, following ADR-009's redact-at-the-boundary rule.
The RFC itself puts a plain-language summary before each YAML example for screen readers, which this page follows. It is silent on
how a falsifying binding or a minimized tuple should read under --accessibility on; roadmap 7.6.3 plans to document
how the framework's results and diagnostics render in that mode, and property reports would need the same treatment.