Module 04 — Make Semantics Under Pressure: CLI, Precedence, Includes, and Rule Edge-Cases¶
Modules 01–03 taught you to build truthful graphs, survive -j, and enforce determinism and CI contracts. This module is the thing you reach for when you already know what you intend, but you need the exact semantics and the sharp edges—fast, provable, and without folklore.
Capstone exists here as corroboration: a runnable place where these semantics are exercised. The module stands on its own.
1) Table of Contents¶
- Table of Contents
- Learning Outcomes
- How to Use This Module
- Core 1 — CLI Semantics and Debug Control
- Core 2 — Variables: Precedence, Scope, Export, Expansion
- Core 3 — Conditionals and Capability Gates
- Core 4 — Includes, Search, and Remake Semantics
- Core 5 — Rule Semantics and Special Targets
- Capstone Sidebar
- Exercises
- Closing Criteria
2) Learning Outcomes¶
By the end of this module, you can:
- Pick CLI options that isolate a failure mode (rather than mask it) and prove what changed.
- Predict variable resolution using precedence and scope, then confirm it empirically with introspection.
- Write conditional logic that gates capabilities (not OS matrices), without injecting hidden inputs.
- Use includes as architecture (layering + overrides) while staying aware of restart/remake semantics.
- Use advanced rule features (multi-output, selection, parallel controls) without breaking convergence or
-jsafety. - Maintain a “special targets” mental map: what each does, when it’s justified, and what it ruins.
3) How to Use This Module¶
3.1 The incident loop¶
When something looks wrong, you do this—always:
- Preview what would run (no execution):
$(warning ...),$(info ...),$(error ...)
3.2 What “correct” means in Module 04¶
This module is passed only if you can do all of this on demand:
- Given a symptom, choose the right semantic tool (
-W,-B,-q,-p,--trace) and show the line(s) that prove the cause. - Given a variable’s surprising value, prove origin + flavor + raw value, and show where it was last set.
-
Given an “only under
-j” issue, identify whether it is: -
missing edge,
- multi-writer output,
- non-atomic publish,
- unsafe multi-output generator rule,
- or parallel-control misuse.
3.3 The two-minute harness¶
Every concept in this module can be validated with a tiny scratch Makefile:
You will replace that Makefile per “Minimal repro” blocks below.
4) Core 1 — CLI Semantics and Debug Control¶
Definition¶
The CLI is part of Make’s observable semantics: it can alter scheduling, staleness assumptions, built-in rule availability, verbosity, and exit behavior.
Semantics¶
1) The small set of options that actually change outcomes¶
| Option | What it really does | Use it for | Don’t use it for |
|---|---|---|---|
-n |
Expands/evaluates, but doesn’t run recipes | preview DAG actions | assuming it “does nothing” (it still expands $(shell ...)) |
--trace |
Prints why each target ran | causality proof | performance diagnosis |
-p |
Dumps the evaluated rule/variable universe | forensic truth | routine use in CI logs |
-W file |
Pretends file is newer than its targets |
simulate skew/staleness | “fixing” dependency bugs by lying |
-B |
Pretends everything is out-of-date | smoke a clean-ish rebuild | replacing correct prerequisites |
-rR |
Disables built-in rules and vars | determinism + explicitness | builds that secretly rely on suffix magic |
-C dir / -f file |
Changes root / selects Makefile | orchestration | papering over bad structure |
2) Exit codes you must not misunderstand¶
make <target>:0success,2error-
make -q <target>(query mode): -
0up-to-date 1would rebuild (not an “error”; it’s a signal)2actual error
Failure signatures¶
- “CI says it would rebuild but local says green.” → you never used
-qor you misread exit1. - “It only fails when I add
-n.” → you have parse-time effects ($(shell ...),!=, generated includes). - “
-B‘fixes’ it.” → your DAG is lying; you’re bypassing correctness.
Minimal repro¶
Repro A: -W proving stale edges without touching files
Run:
Fix pattern¶
- Use
-Wto simulate staleness only while diagnosing. - Use
-qto encode convergence checks (Module 03 selftests). - Use
-rRin CI baselines if you want builds that don’t depend on implicit defaults.
Proof hook¶
- Prove query semantics:
Expected: first prints 0, second prints 1.
5) Core 2 — Variables: Precedence, Scope, Export, Expansion¶
Definition¶
Variable behavior is Make’s hidden state machine: precedence decides which value wins, expansion decides when it is computed, and scope decides where it applies.
Semantics¶
5.1 Precedence ladder (practical model)¶
Highest wins:
- Command line:
make VAR=... overridein makefiles (can beat command line; use sparingly)- Makefile assignments
- Environment (unless
-eis used; with-eenv jumps above makefile) - Built-in defaults
5.2 Expansion modes (the two that matter)¶
| Operator | Expansion time | What you use it for | Typical failure |
|---|---|---|---|
:= |
immediate (“simple”) | stable lists, computed once | rarely the problem |
= |
deferred (“recursive”) | true laziness (rare) | self-referential growth; nondeterministic $(shell ...) timing |
?= |
conditional default | knobs | “why didn’t it apply?” due to earlier assignment |
+= |
append | flags | duplicates if base is recursive |
!= |
shell assignment | avoid | hidden inputs + unstable timing |
5.3 Target-specific variables (scope trap)¶
Target-specific variables apply to that target and can propagate to prerequisites, but they do not automatically export as environment state to sub-makes.
Failure signatures¶
- “Works locally, breaks in CI.” → env leaked (
-e, exported vars, shell init), or precedence differs. - “This variable explodes / duplicates flags.” → recursive
=plus+=, or self-reference. - “Sub-make didn’t get my flag.” → target-specific vars are not export semantics.
Minimal repro¶
Repro: precedence proven with introspection
CFLAGS := FILE
.PHONY: show show-e
show:
@echo "origin=$(origin CFLAGS) flavor=$(flavor CFLAGS) value='$(value CFLAGS)'"
show-e:
@$(MAKE) --no-print-directory -e show
Run:
Fix pattern¶
- Default to
:=unless you can justify laziness. - Avoid
-eexcept when integrating legacy systems (and then stamp it / attest it). - If exported variables affect outputs: either pin them (
export LC_ALL := C) or model them (stamp/manifest).
Proof hook¶
-
You pass this core if you can point to:
-
the
originoutput for each case (file/env/command), - and you can explain why
-eflips env precedence.
6) Core 3 — Conditionals and Capability Gates¶
Definition¶
Conditionals are configuration. Configuration scales only if it gates capabilities and stays single-source (computed once, used many times).
Semantics¶
6.1 What scales¶
- Capability gating: “do we have feature X?” (Make version / tool exists / platform primitive available)
- Fragments: include a small file per capability/platform rather than nesting branches forever
- Fail-fast: unsupported combos error early, not halfway through a build
6.2 What rots¶
- Deep OS×tool×mode matrices embedded across multiple files
- Scattered
$(shell uname)calls (hidden inputs, duplicated logic) - “Soft fallback” that silently changes correctness behavior
Failure signatures¶
- “Same inputs, different behavior per machine.” → capability detection isn’t centralized or isn’t stamped.
- “The build ‘mostly works’ but outputs differ.” → conditional flags changed but were not modeled (stamp).
Minimal repro¶
Repro: clean capability gate using MAKE_VERSION
# Pretend we require grouped targets (GNU Make ≥ 4.3)
HAVE_GROUPED := $(filter 4.3% 4.4% 4.5% 5%,$(MAKE_VERSION))
ifeq ($(HAVE_GROUPED),)
$(error "need grouped targets (&:) or provide fallback")
endif
all: ; @echo ok
Fix pattern¶
- Put detection in one place.
- Expose capability booleans (
HAVE_X) and use them everywhere. - If detection affects outputs, treat it as an input (stamp/manifest) or pin via contract.
Proof hook¶
- You can prove gating works by running:
and showing the computed feature variable.
7) Core 4 — Includes, Search, and Remake Semantics¶
Definition¶
Includes are architecture. They also introduce a rarely-understood semantic: if Make remakes an included makefile, it restarts evaluation.
Semantics¶
7.1 Include types¶
include foo.mk: missing file is an error-include foo.mk: missing file is ignored (use for local overrides, depfiles)
7.2 Remaking included makefiles (restart model)¶
If an included makefile is out-of-date and has a rule to build it, Make will:
- build the included file,
- then restart and re-read makefiles.
That’s a feature—until you create a loop.
7.3 Include-order forensics¶
MAKEFILE_LIST is the include stack. It is the first tool you use to prove “where did this assignment come from?”
7.4 Search (VPATH/vpath) is a truth hazard¶
It makes Make “find” prerequisites in alternate directories. That can hide where inputs came from. Prefer explicit paths and rooted discovery. If you must use it, treat it as part of the build contract.
Failure signatures¶
- “Make keeps re-reading makefiles / never settles.” → generated include loop or nondeterministic include content.
- “It built the wrong file.” →
VPATHresolved a prerequisite from an unexpected location. - “Overrides behave randomly.” → include order changed; local override file is leaking into CI.
Minimal repro¶
Repro: include restart loop
Run make twice. Then break determinism by writing timestamps into gen.mk and watch it loop.
Fix pattern¶
-
Only generate makefiles if:
-
the generation is deterministic,
- the generator is single-writer + atomic,
- and it is covered by convergence tests.
- Use
-includefor optional local overrides; never require them for correctness.
Proof hook¶
- Print include stack:
Then run make -n and show the include order is stable.
8) Core 5 — Rule Semantics and Special Targets¶
Definition¶
This is where senior engineers still get cut: pattern selection ambiguity, multi-output generators, and special targets that mutate Make’s behavior.
Semantics¶
8.1 Pattern selection and ambiguity control¶
- Keep patterns non-overlapping.
- Prefer static pattern rules when fan-out must be controlled.
- When behavior surprises you:
make -pand search for the chosen rule.
8.2 Multi-output generators: one invocation or you’re lying¶
If one recipe produces multiple outputs, you need semantics that ensure it runs exactly once per logical generation.
- GNU Make ≥ 4.3: grouped targets
&:solves this cleanly. - Fallback: a single stamp target + explicit dependencies (still must be atomic).
8.3 Parallel control primitives are last resorts¶
.NOTPARALLEL: use only when shared mutable state cannot be modeled..WAIT: barrier (GNU Make ≥ 4.4). Prefer real edges first.
Failure signatures¶
- “Only one of the generated files updates.” → multi-output generator modeled as separate rules without grouping/stamp.
- “Two recipes fight over the same file.” → multi-writer output or overlapping patterns.
- “
-jflake disappears with.NOTPARALLEL.” → you silenced a real bug instead of fixing the DAG.
Minimal repro¶
Repro: broken multi-output generator (double invocation)
# gen produces a.h and a.c; naive rule causes multiple calls
a.h a.c: gen.py
@python3 gen.py
all: a.h a.c
Under parallel, you can get duplicate runs or partial updates depending on timestamps.
Fix pattern¶
Fix A (GNU Make ≥ 4.3): grouped targets
Fix B (portable): stamp governs generation
GEN_STAMP := build/gen.stamp
$(GEN_STAMP): gen.py | build/
@python3 gen.py && touch $@
a.h a.c: $(GEN_STAMP)
Proof hook¶
- Run:
Expected: generator runs once per logical regeneration; no duplicated recipe lines.
9) Capstone Sidebar¶
Use this to corroborate semantics with a real tree; don’t outsource understanding to it.
Where to look¶
- CLI/forensics patterns:
make-capstone/Makefile - Variable probes / capability gates:
make-capstone/mk/contract.mk - Includes/layering:
make-capstone/mk/common.mk+mk/*.mk - Multi-output / eval demos:
make-capstone/mk/rules_eval.mk - Selftest harness:
make-capstone/tests/run.sh
Runbook¶
make -C make-capstone selftest
make -C make-capstone --trace all
make -C make-capstone -p | less
make -C make-capstone portability-audit
make -C make-capstone USE_EVAL=yes eval-demo
What each proves:
selftest: convergence + serial/parallel equivalence + negative hidden-input detection--trace: causality is explicit and readable-p: the evaluated truth (variables, rules, resolved lists)portability-audit: feature gates computed, not guessedeval-demo: bounded rule generation remains inspectable
10) Exercises¶
Format is Task → Expected → Forensics → Fix. Do these first in a scratch directory, then repeat in capstone where applicable.
Exercise 1 — -q exit codes are not optional knowledge¶
- Task:
0 then 1.
* Forensics: explain why 1 is not “error.”
* Fix: if your CI treats 1 as a crash, your CI script is wrong.
Exercise 2 — Prove variable origin and flavor¶
- Task: create
showtarget (Core 2 repro) and run env/CLI variants. - Expected:
originflips as predicted;flavormatches:=vs=. - Forensics: show the exact
origin=output lines. - Fix: eliminate
-e, eliminate recursive self-references, or stamp exported inputs.
Exercise 3 — Generated include restart loop (and how to stop it)¶
- Task: use Core 4 repro; then modify generator to write timestamps.
- Expected: Make never stabilizes / keeps remaking.
- Forensics:
make --traceshows remake/restart behavior. - Fix: make generated includes deterministic, or stop generating makefiles.
Exercise 4 — Multi-output generator: prove single invocation¶
- Task: implement the broken multi-output rule; run
make -j4 --trace. - Expected: you see duplicated generator runs or inconsistent rebuild behavior.
- Forensics: count generator invocations in trace output.
- Fix: grouped targets (
&:) or stamp-governed generation; rerun and show exactly one invocation.
Exercise 5 — .PHONY misuse causes rebuild loops¶
- Task:
then build twice.
* Expected: app rebuilds every time (bug).
* Forensics: --trace shows .PHONY forces out-of-date.
* Fix: .PHONY only for non-file orchestration targets.
11) Closing Criteria¶
You pass Module 04 only if you can do all of the following without guessing:
- Given “why did it rebuild?”, you can produce the
--traceline that proves the reason. - Given “why did this variable change?”, you can show origin + flavor + value and point to the assigning file/line (via
-pand include stack). - Given “only fails under
-j”, you can classify it as missing edge vs multi-writer vs non-atomic publish vs multi-output rule bug, and apply the correct fix. - Given a feature (
&:,.WAIT,.ONESHELL,--output-sync), you can state the capability gate and provide a fallback that preserves correctness.