Conditionals and Capability Gates¶
Page Maps¶
graph LR
family["Reproducible Research"]
program["Deep Dive Make"]
section["Rule Semantics Precedence Edge Cases"]
page["Conditionals and Capability Gates"]
capstone["Capstone evidence"]
family --> program --> section --> page
page -.applies in.-> capstone
flowchart LR
orient["Orient on the page map"] --> read["Read the main claim and examples"]
read --> inspect["Inspect the related code, proof, or capstone surface"]
inspect --> verify["Run or review the verification path"]
verify --> apply["Apply the idea back to the module and capstone"]
Make conditionals are evaluated while Make reads makefiles. They do not wait for a target to run, and they do not execute inside a recipe shell. That single fact explains many broken capability gates.
Consider this tempting rule:
report: FORMAT := json
ifeq ($(FORMAT),json)
report:
@render --json
else
report:
@render --text
endif
The conditional is decided during parsing, outside the target-specific context for
report. It therefore sees the global value of FORMAT, not the target-specific value.
Automatic variables such as $@ are also unavailable to an ordinary parse-time
conditional.
Use conditionals to choose graph structure from stable configuration. Use recipes or separate targets when a decision belongs to execution time.
Ask for a capability, not a machine label¶
If a rule needs grouped targets, the relevant question is not whether the machine runs Linux. It is whether this Make advertises grouped-target support:
$(.FEATURES) is better evidence than an operating-system name or a hand-written version
range because Make states the capability directly. Use $(MAKE_VERSION) only when the
needed behavior has no feature token, and test the version comparison at the range
boundaries. A filter such as 4.4% 4.5% 5.% silently rejects a future 6.0.
flowchart LR
need["Name the required behavior"]
native["Make exposes a feature token?"]
features["Test .FEATURES"]
probe["Run one bounded capability probe"]
decision["Record yes or no"]
policy["Apply explicit user policy"]
evidence["Print decision and inputs"]
need --> native
native -->|yes| features --> decision
native -->|no| probe --> decision
decision --> policy --> evidence
Platform checks remain appropriate when platform identity is itself part of the contract. They are weak proxies for tool behavior.
Separate discovery, policy, and implementation¶
A reviewable gate has three layers:
| Layer | Question | Example value |
|---|---|---|
| discovery | what can this environment do? | HAVE_ZSTD := yes |
| policy | what did the caller request? | COMPRESSION := auto |
| implementation | which rule path will run? | COMPRESSION_ENGINE := zstd |
Mixing these layers produces ambiguous booleans. ENABLE_ZSTD=no might mean "the caller
forbade it," "the probe failed," or "a default chose something else."
Use a three-state user interface:
COMPRESSION ?= auto
VALID_COMPRESSION_MODES := auto enabled disabled
ifneq ($(words $(COMPRESSION)),1)
$(error COMPRESSION must be exactly one of: $(VALID_COMPRESSION_MODES))
endif
ifeq ($(filter $(COMPRESSION),$(VALID_COMPRESSION_MODES)),)
$(error COMPRESSION must be one of: $(VALID_COMPRESSION_MODES))
endif
HAVE_ZSTD := $(if $(shell command -v zstd >/dev/null 2>&1 && printf yes),yes,no)
ifeq ($(COMPRESSION),enabled)
ifneq ($(HAVE_ZSTD),yes)
$(error COMPRESSION=enabled requires zstd)
endif
COMPRESSION_ENGINE := zstd
else ifeq ($(COMPRESSION),disabled)
COMPRESSION_ENGINE := none
else ifeq ($(HAVE_ZSTD),yes)
COMPRESSION_ENGINE := zstd
else
COMPRESSION_ENGINE := none
endif
The input validation is intentionally strict. Empty, true, and on are rejected rather
than silently interpreted.
A fallback needs an equivalence contract¶
auto is safe only when both branches satisfy the promised output contract. Before
calling one branch a fallback, compare:
| Property | Native path | Fallback path | Required evidence |
|---|---|---|---|
| output names | same declared paths | same declared paths | manifest diff |
| content meaning | compressed archive | uncompressed archive | not equivalent unless contract permits both |
| validation | schema checked | schema skipped | not equivalent |
| determinism | normalized metadata | host timestamps | not equivalent |
| failure behavior | nonzero on invalid input | succeeds | not equivalent |
If a missing tool changes artifact meaning, the choices are:
- require it and fail during parsing with
$(error ...) - make the differing artifact mode an explicit user choice
- or provide a tested implementation with equivalent outputs
"Continue somehow" is not a reproducibility policy.
Parse-time probes are effects¶
$(shell ...) runs when its expansion is evaluated. In a simply expanded assignment at
top level, that means parsing:
This probe may run during:
gmakegmake -ngmake -qgmake -p- every reparse after Make remakes an included makefile
The command must therefore be read-only, bounded, quiet, and deterministic for the inputs you claim. Never put installation, network access, file mutation, or credential lookup in a parse-time probe.
For a costly or environment-sensitive check, make discovery an explicit target that writes a governed configuration file. Review and attest that file as an input rather than reprobing invisibly on every parse.
Centralize once, observe once¶
Store capability discovery in one included fragment:
# mk/capabilities.mk
HAVE_GROUPED_TARGETS := $(if $(filter grouped-target,$(.FEATURES)),yes,no)
HAVE_ZSTD := $(if $(shell command -v zstd >/dev/null 2>&1 && printf yes),yes,no)
Consume the named result elsewhere:
include mk/capabilities.mk
ifeq ($(HAVE_GROUPED_TARGETS),yes)
include mk/grouped-output-rules.mk
else
include mk/stamp-output-rules.mk
endif
Do not repeat the probe in both included rule files. Duplicated discovery can disagree across parse restarts and gives reviewers several sources of policy.
Add an evidence target:
.PHONY: show-capabilities
show-capabilities:
@printf 'MAKE_VERSION=%s\n' '$(MAKE_VERSION)'
@printf 'FEATURES=%s\n' '$(.FEATURES)'
@printf 'HAVE_GROUPED_TARGETS=%s\n' '$(HAVE_GROUPED_TARGETS)'
@printf 'COMPRESSION origin=%s value=%s\n' \
'$(origin COMPRESSION)' '$(COMPRESSION)'
@printf 'HAVE_ZSTD=%s engine=%s\n' \
'$(HAVE_ZSTD)' '$(COMPRESSION_ENGINE)'
gmake -pRrq : is useful for database inspection, but a focused evidence target is easier
to compare in a review and less likely to bury the deciding facts.
Build a falsifiable decision table¶
Test the gate as a matrix, not as one successful local run:
| Caller input | Discovered capability | Expected decision |
|---|---|---|
enabled |
yes | use the capability |
enabled |
no | reject before recipes run |
disabled |
yes | do not use the capability |
disabled |
no | do not use the capability |
auto |
yes | use it only under the equivalence contract |
auto |
no | use an equivalent fallback |
| invalid or empty | either | reject with allowed values |
To test both discovery outcomes without uninstalling tools, give the probe a narrow test seam:
CAPABILITY_PROBE ?= command -v zstd >/dev/null 2>&1
HAVE_ZSTD := $(if $(shell $(CAPABILITY_PROBE) && printf yes),yes,no)
Production keeps the default. A harness can invoke:
gmake CAPABILITY_PROBE=true COMPRESSION=enabled show-capabilities
gmake CAPABILITY_PROBE=false COMPRESSION=enabled show-capabilities
gmake CAPABILITY_PROBE=false COMPRESSION=disabled show-capabilities
The second command must fail. The third must succeed without selecting zstd. Keep this
test seam local to the capability fragment; do not turn arbitrary shell text into a broad
public interface.
Failure signatures and the evidence they need¶
| Symptom | Likely mistake | Evidence |
|---|---|---|
| branch ignores a target-specific value | parse-time conditional expected target context | print the global origin/value while parsing |
| dry run changes state | $(shell ...) or $(file ...) has a parse effect |
compare filesystem before and after gmake -n |
| machines choose different branches | undeclared environment capability | capability evidence target plus tool identity |
| enabled mode quietly degrades | policy and discovery were merged | decision-matrix rejection case |
| fallback bytes differ | no equivalence contract | manifests and digest comparison |
| future Make release takes old path | brittle version filter | boundary tests or .FEATURES token |
Practice before moving on¶
Choose one capstone conditional and produce all of the following:
- a sentence naming the required behavior without naming an operating system
- one centralized discovery variable
- a validated
auto|enabled|disabledcaller policy - a decision table including invalid input and unavailable-capability cases
- an evidence target that prints source inputs and the selected implementation
- proof that
gmake -nperforms no mutation during discovery - either an output-equivalence proof or an explicit rejection of fallback
The work is incomplete if only the happy branch has been run.
End-of-page checkpoint¶
Before leaving, make sure you can explain:
- why ordinary Make conditionals cannot see
$@or target-specific context - when
.FEATURESis stronger evidence thanMAKE_VERSION - why discovery, caller policy, and implementation choice need different variables
- why parse-time shell probes must remain read-only under diagnostic modes and restarts
- why
autois safe only when the alternate paths satisfy one output contract - how a rejection case makes a capability gate falsifiable