Forthcoming capability
Proposed · no release named Proposed RFC

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 test command, and nothing on Netsuke's main branch implements forall, mutations, for_all_actions, or result.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)
targetThe build edge's primary output target name.
ruleThe owning rule name.
argvThe tokenized command, post-interpolation, pre-quoting.
envThe constructed environment map for the invocation.
cwdThe working directory, when the action declares one.
inputsThe resolved $in bindings.
outputsThe resolved $out bindings.
pool, depfile, dyndepThe 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.

Proposed syntax, verbatim from RFC 0012 §3
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_ofOne member of a finite setExhaustive
permutations_ofOne ordering of the listed itemsExhaustive up to the ceiling
intAn integer from an inclusive rangeExhaustive up to the ceiling
path_fragmentA sandbox-safe relative path tokenSampled
list_ofA bounded list over a child domainSampled

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.

Proposed syntax, verbatim from RFC 0012 §4
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_declarationsActions and Ninja output are identical.
insert_irrelevant_targetPre-existing actions are unchanged.
rename_targetActions are isomorphic under the renaming.
remove_declared_inputThe 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.

Adapted: two flags named in RFC 0012 prose, which shows no combined invocation
$ 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-tests/hello.yml — dialect 1.0, verbatim from the testing framework UX design §14.1
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. 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. 2. mutations, with no expressions

    Declare "1.1", point a case at an existing manifest, and list permute_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. 3. forall over small finite sets

    one_of, permutations_of, and small int ranges enumerate exhaustively under the ceiling, so every combination is checked rather than sampled, with for_all_actions assertions over each. Sampled domains such as path_fragment and list_of come after, with seeds and replay.

  4. 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. 5. Gate on --strict-coverage

    Once 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.actions should 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.