Mar 02, 2026 · infra · 12 min read
Makefile is the em-dash of agentic coding
I’ve developed a low-confidence code smell for repos, and it’s not // TODO: add tests or suspiciously polite commit messages.
It’s a wrapper Makefile.
Like an em-dash, it’s connective tissue: often a useful shortcut for flow, but easy to reach for when the underlying structure should probably be doing the work.
Not the “we’re going to model a build graph in GNU Make” kind—just a tiny forwarder to the real toolchain:
TL;DR
- Wrapper Makefiles are fine; the smell is a second interface that isn’t clearly owned.
- Delete the wrapper if it adds no value or docs/CI don’t use it.
- Keep it if it’s the canonical interface and it stabilizes env/flags across humans + CI.
- If native Windows is a requirement, Make-as-“universal” is usually a mismatch.
- If you want a nicer verb palette and can add a dependency, use
just/task.
Agentic coding (LLM-assisted multi-step workflows) tends to prefer boring, ubiquitous interfaces—so wrappers keep getting rediscovered.
(Make recipes start with a tab. This is part of the friction.)
lint:
npm run lint
test:
npm run test
This is not an AI detector. It’s a weak signal that’s only interesting when it mismatches the repo.
Also: don’t use this for attribution. The point of the smell is to ask “does this add value here?” — not “who wrote it?”.
Wrapper Makefiles predate LLMs; what’s new is how often they get added as a second interface instead of becoming the owned, canonical one.
The “tell”, when it works at all, is mismatch: a thin forwarding Makefile added late to a repo that already had an idiomatic task surface, without solving a real problem.
Concretely, that mismatch often looks like:
- The repo already had
npm run/uv run/cargoentrypoints and docs, but the Makefile duplicates them without adding anything. - It’s orphaned: CI still runs the old commands and the README still points at
npm run …, so the Makefile isn’t part of the repo’s actual automation surface. - The Makefile reads like low Make literacy: missing
.PHONY/help(somake testcan silently do nothing if a file/dir namedtestexists). - Portability footguns: recipes that assume one shell session, or bashisms that break under POSIX
sh(andshvaries across systems).
A pragmatic decision guide
| Situation | Default choice | Why |
|---|---|---|
| Single-ecosystem repo | native toolchain (npm run, cargo, uv, go test) | lowest surprise for contributors |
| Polyglot repo | scripts/ + a short README | minimal extra tooling, easy to debug |
| Container-first repo | docker compose / devcontainer tasks | pins tools and environment, reduces drift |
| You want nicer ergonomics | just or task | better UX, but adds an install step |
| Org wants standardized verbs | wrapper layer (Make or equivalent) | acceptable if documented and consistent |
My rule:
- If the wrapper adds no value (it only duplicates existing entrypoints) or isn’t the thing docs + CI actually use, delete it.
- If it normalizes environment/flags and is the canonical surface for humans + CI, keep it — and make it humane.
The key trade: Make is ubiquitous, but sharp. just/task are friendlier, but you pay an install/bootstrapping cost. A scripts/ directory is boring, but it’s also extremely legible.
If you can’t guarantee that developers and CI images have your task runner installed, make’s ubiquity is the deciding factor. It’s basically its only superpower here.
One caveat: scripts/ isn’t automatically “more portable” than Make. Shell scripts have their own footguns (bash vs sh, Windows, env handling). If you go the scripts/ route, make it humane:
./scripts/lint,./scripts/test,./scripts/build(real verbs, stable names)set -eu(and be explicit about bashisms if you use them)./scripts/helpthat prints the available commands
Windows is the hard counterexample
If you require native Windows (PowerShell/CMD without WSL/devcontainers), Make as “universal” usually adds a new dependency and ambiguity: WSL vs MSYS2 vs Git Bash, different behavior, different assumptions. It’s a failure of local fit.
The wrapper Makefile feels like an em-dash: useful connective tissue, easy to overuse for “flow,” and a little too tempting when the underlying structure should carry the load. (Yes, the em-dashes are on purpose.)
To be clear: I don’t think Makefiles are “back”. As a build system, Make mostly belongs to older codebases and ecosystems where it already fits. For anything new, I’d rather use the native toolchain (cargo, go test, npm run, uv, …) or a modern task runner like just.
But as a tiny, boring, nearly-universal interface for “these are the verbs that matter here”, Make keeps showing up.
One concrete “good wrapper added late” story: a team migrates from yarn → pnpm (or “plain docker” → compose) and keeps make test stable while the internals change. The wrapper isn’t the build system; it’s the compatibility layer.
make <verb> stuck because, for a long time, it was the universal front door to builds (./configure; make; make install). That habit never really died.
How I met Make (via Autotools)
My build-system origin story is extremely unromantic: I started with Makefiles because they were there.
My first exposure was trying out Gentoo around 2008. If you installed software from source on purpose back then, you met Autotools (Autoconf/Automake/Libtool) quickly — and you met its default ritual:
./configure
make
make install
The part that didn’t click for a long time wasn’t make. It was: where did ./configure come from, and why is it sometimes missing?
Sometimes you didn’t get a ready-to-run ./configure at all. You got configure.ac / configure.in, Makefile.am, and friends. So you’d end up doing some variation of:
autoreconf -fi # or ./autogen.sh (if the project had one)
./configure --prefix="$HOME/.local"
make
make install
Also: in a multi-user environment, I learned to treat make install like a loaded weapon. I often didn’t run it unless I had to — partly because DESTDIR/staging support wasn’t consistently implemented across projects, and partly because I didn’t want to discover (the hard way) which env vars a particular build respected for install paths.
Don’t blindly install into /usr on a shared machine. Prefer a user-local prefix (like $HOME/.local) or /usr/local, and when staging does work (DESTDIR=...), inspect what it would install before it touches the real filesystem. This was all before “just spin up a clean env” was as normal as it is now.
Later, during my studies, it got reinforced by the “this isn’t packaged for your distro (or the last package is years old)” reality: Autoconf projects, hand-rolled build steps, and a lot of “read the README, then stare at the error”.
And later still, during sysadmin time at HU, I spent plenty of time on the other side of the same problem: building, packaging, and dealing with the sharp edges when “just install the package” doesn’t quite work.
make predates me by decades (early versions land around 1976, the whole lineage of “write dependencies down and stop typing the same command”). But culturally it stayed current: if you touched Linux-y C code, you touched make.
I picked up the surface-level stuff early:
- targets are verbs
- dependencies are “inputs”
- recipes are “how”
.PHONYexists for a reason (and yes, I finally remember it’s.PHONY, not whatever my brain wants it to be at 2 AM)
I got productive. I did not get wise.
In hindsight, Autotools was my first “I don’t actually understand this”: autoconf, automake, libtool, the mysterious m4 macros, and the sacred incantations:
./configure
make
make install
I could use it. I could sometimes debug it by staring at config.log long enough.
But if you asked me to confidently explain why some AC_* macro expands the way it does, or why a project’s Makefile.in looks like it was printed by a haunted typewriter, I’d still fail the oral exam.
Autoconf felt like a cathedral of portability built out of shell scripts, historical compromises, and defensive programming. I respected it. I did not feel invited.
That experience is why I still think of make less as “the build system” and more as durable glue: a stable make <verb> protocol that hides some genuinely terrifying internals—and why I notice when that glue shows up where it doesn’t stick to anything.
Then CMake (briefly) felt like the promised land
During my bachelor’s, I had a phase where I genuinely believed CMake would fix everything.
It was newer (at least in vibe), cross-platform, IDE-friendly, and it looked like the grown-up answer to “please stop teaching freshmen ./configure”.
And to be fair: for a lot of projects it did fix things. Or at least it moved the pain around into different corners.
But it also taught me a lesson I keep re-learning:
Build systems don’t remove complexity; they relocate it.
The modern “just use X” era
These days the default suggestion depends on your ecosystem:
- Rust?
cargo. - Go?
go test ./.... - Node?
npm run …or a task runner. - Python?
uv+ scripts. - Polyglot repo? usually
scripts/orjust. At larger scales:nixfor environments, Bazel when you’re already in Bazel-land.
And I like most of those options. Especially just: it’s ergonomic, readable, and feels like what I wish Make felt like when I’m tired.
The DevEx gap (especially for juniors)
Makefiles can do the same to developer experience:
- Weird quoting rules.
- Tabs that matter.
- Error output that’s sometimes… interpretive.
- Implicit behavior that only makes sense if you’ve lived in Unix long enough.
Another common wrapper footgun: multi-line recipes that look like scripts, but run each line in a fresh shell unless you opt into .ONESHELL:.
For junior people—especially those who’ve never had to compile classic Linux C code—make can feel like a trap door into someone else’s basement.
And this is where the “mismatch” argument becomes defensible: wrappers are often added in the name of “DevEx”, but for a lot of modern teams they do the opposite.
In a modern JS/Python repo aimed at a junior-heavy team, a late-added forwarding Makefile is usually a sign of “one command to rule them all” thinking. Attribution is unreliable, but the mismatch is still worth noticing.
If your repo is primarily JavaScript or Python, the most developer-friendly interface is usually the one people already expect:
npm run lint/pnpm lintuv run pytestpython -m typeror a smallscripts/directoryjust lintif you want a nicer “verb palette” without Make’s sharp edges
Make is a fine interface for people who grew up in Make-shaped ecosystems. It’s not a universal “lowest cognitive load” tool anymore, even if it’s widely installed.
And that’s why the wrapper smell is about ownership: when make <verb> becomes a second interface, someone has to own it.
If you do use Make, make it humane
A wrapper Makefile that’s meant for humans should behave like an interface, not a rite of passage:
- declare
.PHONYtargets - provide a
helptarget - keep targets as thin forwards (don’t rebuild a build system)
- fail loudly and consistently
- be explicit about shell assumptions (avoid bashisms, or declare them via
SHELL/SHELLFLAGS) - remember each recipe line runs in its own shell by default; keep recipes single-line, use
&&, or delegate to scripts (reach for.ONESHELL:deliberately, and treat it as a trade-off)
For anything more complex than a thin forward, delegate to a scripts/ directory rather than trying to perfect Make shell behavior with SHELLFLAGS.
For example:
.PHONY: help lint test build
# Shell differs across OSes; if you need bash, declare it and commit to it.
SHELL := /bin/sh
.DEFAULT_GOAL := help
help:
@echo "make lint # run lint"
@echo "make test # run unit tests"
@echo "make build # build the site"
lint:
npm run lint
test:
npm run test
build:
npm run build
If you can afford one more dependency, a minimal justfile reads like this (justfile syntax is Make-like, but this isn’t Make):
# justfile
default := "help"
help:
@echo "just lint # run lint"
@echo "just test # run unit tests"
@echo "just build # build the site"
lint:
npm run lint
test:
npm run test
build:
npm run build
(In CI, install just explicitly or keep Make as the zero-dependency option.)
Also: “added late” is a specific smell. I mean “after the repo already had stable docs + CI entrypoints for months/years, and contributors were already aligned on the existing interface” — not “someone added a Makefile at some point”.
If you inherited this
If you join a repo and find three different “interfaces”, treat it like a small hygiene task:
- Pick a single canonical surface (
npm run …,scripts/…, Make,just, etc.) and document it. - Make CI call the same surface developers use.
- Delete duplicates, or make wrappers add real value (consistent env loading, consistent flags) instead of just mirror commands.
- Add
.PHONY+help, and be explicit about shell assumptions so it behaves the same across machines.
So: why use a Makefile for anything new?
If you asked me to set the default for greenfield repos, I’d say “don’t”. Use the native toolchain, keep a scripts/ directory for glue, and if you want a command palette, pick a task runner that matches your audience (just has the nicest ergonomics-for-humans curve right now).
Make is good glue. Just don’t mistake “one command” for “better DevEx,” especially when it creates a second interface that isn’t clearly owned.