dowel

Semantics: how a manifest becomes a build

What happens between saving a manifest and running an artifact. Syntax and the property tables are in 12-build-reference.md; this page defines what the declared values do.

1. The pipeline

parse → evaluate → specialize → propagate & merge → plan → execute
Stage Input Output Runs when
parse file text lossless syntax tree file content changed
evaluate tree typed values with provenance file content changed
specialize values + configuration concrete values (no Cfg<T> left) per configuration
propagate & merge per-target blocks + dependency graph one merged property map per target when the span-free summary changed
plan merged maps action graph, build.ninja, compile_commands.json per build
execute action graph artifacts per build

Two consequences worth knowing:

2. Loading and the dependency graph

Loading starts at the package in --directory and follows dependencies transitively. For each package, dowel.toml is read (11-toml-reference.md), then dowel.build defines its targets.

3. Specialization

Specialization turns Cfg<T> values into T for one configuration:

dowel why shows both sides: which arm was chosen, and what a false predicate dropped (DOWEL_LOG=trace logs each decision as it happens).

4. Propagation and merging

Each target’s declared blocks combine with its dependencies’ interfaces. Two derived maps exist per target:

interface(T)   = public(T)  +  interface of each target in public(T).deps
compile_env(T) = public(T)  +  private(T)  +  interface of every dependency of T

This pair is the meaning of public / private:

Order: values arrive self-first, dependencies after — the order include search and linking expect. Within dependencies, the graph’s topological order applies.

Merging happens per property, under the rule declared in the schema (12-build-reference.md):

Rule Behavior
union duplicates dropped, arrival order kept. Two equal-looking paths from different packages are not duplicates — a path’s base point is the package that declared it, so dir("include") in two packages names two directories
append concatenation, duplicates kept
error_on_conflict per map key: the same value may arrive many times, but two different values for one key fail (merge-conflict) with both provenance chains in the diagnostic
must_equal all arriving values must be identical or the build fails (abi-mismatch). This is the whole ABI check today: abi labels are compared before linking, turning a would-be runtime ODR breakage into a build failure. The label c is exempt — see below
replace last arrival wins (used by runner properties, which do not propagate)
max the highest value in the vocabulary’s order wins. Used by c_std / cxx_std: a library requiring C++17 consumed by a C++20 binary is correct, and a library requiring C++20 raises a consumer that asked for less (ADR-0016)

Nested lists are flattened completely during merging — a match written as a list element produces a list-in-a-list when specialized, and one level of flattening would silently drop it downstream.

The c ABI label

An abi label may name a boundary instead of a language (ADR-0019). One such label exists:

[lib.hashx.public]
abi = "c"          # this surface is the C ABI

A c label matches every label. It does not participate in the must_equal comparison, the merged value is the first label that is not c, and only when every label is c is the result c.

The reason is that the check is about ODR, and ODR violations do not arise across an extern "C" boundary: a C function has no overloading, no templates, no inline instantiation, and no name mangling. Without this, a C library and a C++ consumer — each stating its own language honestly — produce different labels and the build is refused, and the only way out is for the consumer to copy the library’s label. That makes the label name “the set of things that use this library” rather than an ABI. A library is also written without knowing its consumers, so one language label there forces that language on all of them.

c never weakens a check it was not asked to weaken. It declines to add a constraint; it does not remove one. A gnu11 arriving through a c surface from further down is still compared, because that constraint is real.

Every merged value keeps the full provenance chain, which is what dowel why <target> <property> prints:

include/                          Path
  ← public.includes of target:foo       libfoo/dowel.build:18
    ← deps of target:app                app/dowel.build:7

5. Planning

Planning turns merged property maps into an action graph.

6. Execution

7. Incrementality and the store

Evaluation results are memoized in-process and persisted in .dowel/cache/; unchanged files (judged by stat, then content fingerprint) are not even re-lexed on the next run. All of this is invisible to semantics: deleting the store, losing the writer lock, or corrupting the cache changes speed only, never results. Details are in 20-architecture.md; observable behavior (verdicts, restore counts) appears under DOWEL_LOG=debug / trace (91-implementation-status.md).

8. Diagnostics as part of the semantics

Any rule on this page that says “fails” produces a located diagnostic with a stable code (merge-conflict, abi-mismatch, undeclared-dependency, missing-toolchain, …), notes carrying the provenance of the offending values, and — where a fix is mechanical — a fix suggestion that can be applied from --message-format=json. The full code list, with the minimal input that triggers each, is the case table in crates/dowel-cli/tests/diagnostics.rs, and the coverage check keeps it complete.