Repository navigation
Expand file tree
/
Copy pathMakefile
More file actions
648 lines (593 loc) · 34 KB
/
Copy pathMakefile
File metadata and controls
648 lines (593 loc) · 34 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
# The one binary. Every build lands here — never at the repo root, never
# anywhere else — so a stale copy can't shadow a fresh one.
BINARY := bin/codeaf
.PHONY: all build build-check build-cross app-sidecar app-sidecar-reverse app-sidecars debug demo-home clean-run embed manual-pack-law furrow test test-focus test-report test-quick test-tooling test-touched test-touched-preflight pr-ready test-laws test-carried-help test-belt-pages fmt-check test-packed-manual manual-gates test-remote test-e2e test-e2e-tui vet check size clean \
changelog changelog-new changelog-check changelog-preview
# What the shipped binary is allowed to weigh, in bytes, checked in beside the
# tree that produces it. See `size` below for why it is a file and not a number
# in this Makefile, and PERF.md for the policy around changing it.
BUDGET := SIZE-BUDGET
# These three words are the build's identity everywhere the program reports
# one. The timestamp is UTC at the seam and becomes local time only when a
# person reads it, so the same binary remains unambiguous across machines.
BUILD_REV := $(shell git rev-parse --short HEAD)
BUILD_DIRTY := $(shell if test -n "$$(git status --porcelain --untracked-files=normal)"; then printf true; else printf false; fi)
BUILD_AT := $(shell date -u +%Y-%m-%dT%H:%M:%SZ)
BUILDINFO := github.com/Agent-Field/codeaf/internal/buildinfo
BUILD_STAMP := -X $(BUILDINFO).rev=$(BUILD_REV) -X $(BUILDINFO).dirty=$(BUILD_DIRTY) -X $(BUILDINFO).builtAt=$(BUILD_AT)
# The GitHub OAuth app the first run's browser sign-in uses. Its client ID is
# not a secret; a build without one simply has no browser sign-in (the gh
# login and a pasted token still work). Set it from the environment or the
# command line: make app-sidecar CODEAF_GITHUB_CLIENT_ID=Ov23li...
CODEAF_GITHUB_CLIENT_ID ?=
ifneq ($(strip $(CODEAF_GITHUB_CLIENT_ID)),)
BUILD_STAMP += -X github.com/Agent-Field/codeaf/internal/factory/github.clientID=$(strip $(CODEAF_GITHUB_CLIENT_ID))
endif
MANUAL_TAG := codeaf_packed_manual
# The packed corpora — the two manuals. Each folder is the source of truth and
# the archive beside it is an IGNORED build product (internal/packed says why),
# so a build that skipped generation could ship yesterday's manual. The law
# below checks the index as well as .gitignore: an ignored file can still be
# force-added, which turns every otherwise-independent manual edit into a binary
# merge conflict.
PACKED_PKGS = ./internal/manual
all: build
manual-pack-law:
@if git rev-parse --is-inside-work-tree >/dev/null 2>&1; then \
tracked=$$(git ls-files -- 'internal/manual/*.pack.gz'); \
if test -n "$$tracked"; then \
printf '%s\n' 'generated manual archives must not be tracked:' "$$tracked"; \
printf '%s\n' 'remove them from the index; make build regenerates ignored copies'; \
exit 1; \
fi; \
fi
embed: manual-pack-law
# Generators execute on the build host, even when codeaf targets another OS.
env -u GOOS -u GOARCH go generate $(PACKED_PKGS)
# ── the furrow that rides inside ────────────────────────────────────────────
#
# Every release platform carries furrow, including native Windows, so the build
# fetches it before producing codeaf.
# This step downloads the release pinned in
# internal/furrowbin/pin.json for whatever platform is being built for, checks
# it against the sha256 the pin names, keeps it in a gitignored third_party/
# cache, and stages it gzipped where go:embed picks it up. Six megabytes of
# binary is not committed — every clone would carry it forever — but the pin
# beside it is, so what shipped is auditable from the repository alone.
#
# It is a prerequisite of `build` and not a thing anybody remembers to run. A
# fetch that cannot happen STOPS THE BUILD, loudly, with the command to run: the
# failure mode this ordering exists to make impossible is a quietly successful
# build that produced a codeaf without furrow inside it.
#
# On a machine with no network, point it at an artifact already on disk — the
# explicit artifact is a developer override and announces the pinned version it
# replaces; the normal fetch checks the pin:
#
# make furrow FURROW_ARTIFACT=/path/to/furrow-linux-amd64
#
# THE TARGET IS READ FIRST AND THEN UNSET. A cross-compiling build sets GOOS and
# GOARCH for codeaf, and the fetcher has to know them — but it must not be built
# FOR them, or the build machine tries to run a Linux tool and reports an exec
# format error where it meant to report a download. So the two are captured as
# the platform to fetch and taken out of the environment the tool is built in.
FURROW_GOOS := $(shell go env GOOS)
FURROW_GOARCH := $(shell go env GOARCH)
# Normal builds fetch the pinned release. Explicit artifacts and external source
# checkouts are deliberate developer overrides, staged through the same door.
FURROW_SOURCE ?=
FURROW_SOURCE_TARGET = $(if $(CARGO_TARGET_DIR),$(CARGO_TARGET_DIR),$(FURROW_SOURCE)/target)
furrow:
ifneq ($(strip $(FURROW_SOURCE)),)
cd "$(FURROW_SOURCE)" && cargo build --release --locked -p furrow-cli
@env -u GOOS -u GOARCH go run ./internal/furrowbin/cmd/fetch \
-goos=$(FURROW_GOOS) -goarch=$(FURROW_GOARCH) \
-built "$(FURROW_SOURCE_TARGET)/release/furrow"
else
@env -u GOOS -u GOARCH go run ./internal/furrowbin/cmd/fetch \
-goos=$(FURROW_GOOS) -goarch=$(FURROW_GOARCH) \
$(if $(FURROW_ARTIFACT),-built "$(FURROW_ARTIFACT)")
endif
# The symbol table and DWARF are a third of the shipped binary and nothing at
# runtime reads them. Stripping costs symbolized panic traces, which is exactly
# what `debug` keeps — build that when a stack trace is what you need.
#
# -trimpath drops the build machine's absolute paths out of the binary. The
# build stamp deliberately gives separate builds separate bytes, but neither
# carries the machine-specific repository root. What trimming costs is
# compiled-in repository roots: a lookup that walks up from its own source file
# with runtime.Caller can no longer find a tool checked in beside the tree. A
# shipped binary's compiled-in root never exists on the machine running it
# anyway, so any such lookup has to fall through to the copy beside the cwd and
# then to PATH — which is what it does here.
build: furrow embed
go build -tags=$(MANUAL_TAG) -trimpath -ldflags="-s -w $(BUILD_STAMP)" -o $(BINARY) ./cmd/codeaf
go build -trimpath -ldflags="-s -w $(BUILD_STAMP)" -o bin/plandb ./cmd/plandb
debug: furrow embed
go build -tags=$(MANUAL_TAG) -trimpath -ldflags="$(BUILD_STAMP)" -o $(BINARY) ./cmd/codeaf
# ── the known-red ledger, read once ─────────────────────────────────────────
#
# .github/known-red.txt WAS the debt: tests that failed on a clean tree, skipped
# by name so that red still meant something. It burned to zero on 2026-09-12
# (#1012) and the file is gone; this read remains so that a ledger could not
# quietly return — IT IS READ HERE AND ONLY HERE, and an absent file skips
# nothing. `make test`, `make test-laws` and both workflows go through this one
# reading, so "green locally" and "green in CI" are one fact. Before 2026-09-02
# they were not: this target was a bare `go test ./...` that could not pass on a
# clean tree while the full run skipped the ledger, so `make check` — the ritual
# CLAUDE.md sends everybody to — stopped at its second step for everyone, every
# time (#372).
KNOWN_RED := $(shell grep -v -e '^\#' -e '^[[:space:]]*$$' .github/known-red.txt 2>/dev/null | paste -sd'|' -)
TEST_SKIP := $(if $(KNOWN_RED),-skip '^($(KNOWN_RED))$$')
# THE PER-SHARD TIMEOUT IS MEASURED, NOT GUESSED. On 2026-09-22 internal/tui3
# took 65 seconds as eight shards on the eight-core WSL2 box, down from 341
# seconds serial; the constrained runner's earlier serial measurement was 563
# seconds. Fifteen minutes remains per shard because a busy runner or a changed
# shard can still need the old headroom, and a real hang must name itself.
# Lower it only from a new uncached measurement, never from an expected
# optimization.
TEST_TIMEOUT := 15m
TEST_FLAGS ?=
PKGS ?= ./...
SHARDS ?= $(shell count=$$(nproc 2>/dev/null || sysctl -n hw.ncpu 2>/dev/null || echo 1); if ! test "$$count" -ge 1 2>/dev/null; then count=1; elif test "$$count" -gt 8; then count=8; fi; echo "$$count")
# A full run of the tree or either heavy package takes the box's one suite lock
# (scripts/one-suite.sh says why): each heavy package holds it for its whole
# sharded run, and a `./...` run holds it for the rest of the tree as well, so
# two sessions' tree runs still cannot stack. Focused and lighter runs stay
# independent. cmd/codeaf joined them on 2026-11-05: run whole, it passed the
# fifteen-minute package ceiling on the four-vCPU runner once pull requests into
# app/v3 began running the gate (#2432 timed out at 900s), so it is sharded too.
HEAVY_PKGS := internal/tui3 internal/session cmd/codeaf
LOCKED_PKGS := ./... $(addprefix ./,$(HEAVY_PKGS)) $(addsuffix /,$(addprefix ./,$(HEAVY_PKGS)))
FRESH_FLAG := $(if $(filter $(LOCKED_PKGS),$(PKGS)),-count=1)
SUITE_LOCK := ./scripts/one-suite.sh
TREE_LOCK := $(if $(filter ./...,$(PKGS)),$(SUITE_LOCK))
# EVERY TEST TARGET STAGES THE PINNED FURROW FIRST. The seal, sync and cell
# proofs run codeaf's own engine — the embedded copy, never one on PATH — and a
# test binary built from a tree with nothing staged carries none, so those
# proofs skip (internal/cell/celltest.Engine says which and why). A skip is right
# for a plain `go test` on a laptop and wrong for the gate, which is these
# targets: staging here is what makes "green" include them, and test-touched
# and the pull request's touched legs reach it through scripts/touched-verdict.py,
# which runs this target. It is the same fetch `build` depends on, so it fails
# loudly offline and accepts FURROW_ARTIFACT exactly as a build does.
test: furrow
@set -u; \
module="$$(go list -m)" || exit 1; \
packages="$$(go list $(PKGS))" || exit 1; \
is_heavy() { \
for heavy in $(HEAVY_PKGS); do \
if test "$$1" = "$$module/$$heavy"; then return 0; fi; \
done; \
return 1; \
}; \
has_heavy=; \
for package in $$packages; do \
if is_heavy "$$package"; then has_heavy=yes; fi; \
done; \
if test -z "$$has_heavy"; then \
exec go test -timeout $(TEST_TIMEOUT) $(TEST_FLAGS) $(TEST_SKIP) $(PKGS); \
fi; \
nonheavy=; \
for package in $$packages; do \
if ! is_heavy "$$package"; then nonheavy="$$nonheavy $$package"; fi; \
done; \
status=0; \
if test -n "$$nonheavy"; then \
$(TREE_LOCK) go test -timeout $(TEST_TIMEOUT) $(FRESH_FLAG) $(TEST_FLAGS) $(TEST_SKIP) $$nonheavy || status=1; \
fi; \
for package in $$packages; do \
if is_heavy "$$package"; then \
SHARDS='$(SHARDS)' $(SUITE_LOCK) ./scripts/shard-test.sh -timeout $(TEST_TIMEOUT) $(TEST_FLAGS) $(TEST_SKIP) "$$package" || status=1; \
fi; \
done; \
exit "$$status"
# One named regression is the fastest trustworthy edit loop. RUN is required:
# an omitted selector must not silently turn a focused command into a full
# package run. The ordinary test target still owns the ledger and timeout.
test-focus: furrow
@test -n "$(RUN)" || { echo "usage: make test-focus PKGS=./internal/pkg RUN='^TestName$$'"; exit 2; }
go test -timeout $(TEST_TIMEOUT) $(TEST_FLAGS) -run '$(RUN)' $(TEST_SKIP) $(PKGS)
# Keep the Go build cache warm while forcing the tests themselves to execute.
# REPORT is structured JSON; progress and the slowest completed tests remain
# visible on stderr during a long package run.
REPORT ?= test-report.json
test-report:
./scripts/test-report.sh '$(REPORT)' $(MAKE) -s --no-print-directory test PKGS='$(PKGS)' TEST_FLAGS="$(TEST_FLAGS) -count=1 -json"
# This runs the deterministic light pull-request gates: build, vet, formatting,
# the packed corpus, well-formed change entries, the manual gates, and the laws.
# Off a pull request the changelog check can prove only that entries are well
# formed; whether this branch adds one needs the base commit that only CI has.
# It is fast feedback, NOT full acceptance: it does not run touched packages or
# whole suite. Use pr-ready before claiming pull-request acceptance.
build-check:
go build ./...
test-quick: build-check vet fmt-check test-packed-manual changelog-check manual-gates test-laws test-carried-help test-belt-pages
# The script runners are executable infrastructure that Go's package walk cannot
# discover, so their acceptance scripts are named here. The benchmark clone
# regression is kept beside the helper it drives. They are touched-only, like
# the Go packages: `pr-ready` runs them when the change touches scripts/, the
# covered benchmark paths, or this Makefile. Together they take about forty
# seconds and most pull requests never go near them.
test-tooling:
python3 scripts/hosted-evidence_test.py
bash scripts/touched-packages_test.sh
bash scripts/touched-verdict_test.sh
bash scripts/one-suite_test.sh
bash scripts/shard-test_test.sh
bash scripts/relay-fingerprint_test.sh
bash scripts/relay-wrangler-config_test.sh
bash scripts/raft-proof-footprint_test.sh
bash scripts/relay-deploy_test.sh
bash bench/canary/lib/repo_test.sh
manual-gates:
go test ./internal/manual/
go test -run 'Manual' ./internal/tui3/ ./internal/session/
# THE TOUCHED SET IS THE PULL-REQUEST JOB'S SET. A module-file change reaches
# every package; otherwise each changed Go file contributes its directory, and
# a directory emptied by the change contributes nothing. BASE may name the
# pull request's exact base SHA; on a laptop it defaults to origin/dev. This
# refuses uncommitted Go or module files because the real gate sees BASE..HEAD:
# silently ignoring those files under-tests, while folding a shared checkout's
# unrelated edits into this run over-tests. Commit the candidate, then prove
# exactly what the pull request will send. The preflight is separate so
# `pr-ready` refuses before spending anything on its light checks. Tests still
# go through the same classifier as CI, whose first run uses `make test`
# for the timeout and full heavy-package lock.
test-touched-preflight:
@set -eu; \
base="$${BASE:-origin/dev}"; \
if ! git rev-parse --verify "$$base^{commit}" >/dev/null 2>&1; then \
printf '%s\n' "cannot resolve BASE '$$base'; fetch origin/dev or pass BASE=<commit>" >&2; \
exit 2; \
fi; \
uncommitted="$$( \
{ git diff --name-only HEAD -- '*.go' go.mod go.sum; \
git ls-files --others --exclude-standard -- '*.go' go.mod go.sum; } \
| sort -u \
)"; \
if test -n "$$uncommitted"; then \
printf '%s\n' 'test-touched cannot mirror the PR while Go or module files are uncommitted:' "$$uncommitted" \
'Commit or remove them, then run this target again.' >&2; \
exit 2; \
fi
# The selector is shared with CI; this target adds only the local invocation.
# Every named failure gets one focused rerun and, if needed, a base comparison.
test-touched: test-touched-preflight
@set -eu; \
base="$${BASE:-origin/dev}"; \
pkgs="$$(BASE="$$base" ./scripts/touched-packages.sh)"; \
if test -z "$$pkgs"; then \
echo 'No Go file and no module file changed; nothing to run.'; \
exit 0; \
fi; \
echo "touched:$$pkgs"; \
printf '%s\n' "$$pkgs" | python3 ./scripts/touched-matrix.py run-local --base "$$base" --shards '$(SHARDS)' --timeout '$(TEST_TIMEOUT)'
# One local spelling for the two jobs behind the pull request's required
# `check`: first the deterministic light gate, then the exact touched-package
# proof above. This deliberately omits the full tree, release binary, size
# ratchet, cross builds and remote containers; those remain staging/nightly
# work, with `make check` as the full-tree laptop/Spark spelling.
pr-ready: test-touched-preflight
$(MAKE) --no-print-directory test-quick
@if ! git diff --quiet "$${BASE:-origin/dev}" HEAD -- \
scripts Makefile bench/run.sh bench/canary/lib bench/oneroad/lib/corpus.sh; then \
$(MAKE) --no-print-directory test-tooling; \
fi
$(MAKE) --no-print-directory test-touched
# The laws alone — every test that reads the tree itself — in under half a
# minute. This is what the pull-request gate runs on every change, and
# scripts/laws.sh says how they are found without anybody keeping a list.
test-laws:
./scripts/laws.sh
# Carried help spans the registered programs, so testing only a changed program
# package misses a description that no longer fits the shared eighty-cell page.
test-carried-help:
go test ./internal/delegate/builtin -run '^TestEveryCarriedProgramsHelpFitsEightyColumns$$' -count=1
# Embedded prompt pages do not select a Go package in the touched gate, so the
# existing whole-page budget and belt assertions run on every light gate.
test-belt-pages:
go test -timeout $(TEST_TIMEOUT) ./internal/session -run '^TestABeltWorkerReadsTheBeltsPagesAndNothingElse$$' -count=1
# gofmt is not a preference here. A file gofmt would rewrite is a file the next
# editor's save rewrites, and that diff lands in somebody else's pull request.
fmt-check:
@dirs="$$(go list -f '{{.Dir}}' ./...)" || exit 1; \
test -n "$$dirs" || { echo 'go list found no packages'; exit 1; }; \
files="$$(printf '%s\n' "$$dirs" | tr '\n' '\0' | xargs -0 gofmt -l)" || exit 1; \
if test -n "$$files"; then \
printf '%s\n' 'gofmt would rewrite:' "$$files"; \
exit 1; \
fi
# Exercise the source mode the shipped binary uses. Ordinary Go commands embed
# the Markdown directly so a clean checkout compiles without generated files;
# this target proves the generated, compressed path reads the same pages.
test-packed-manual: embed
go test -tags=$(MANUAL_TAG) ./internal/manual
# TWO MACHINES, ACTUALLY TWO. Three containers on one network — a scripted
# model, an engine with sshd, and a surface — sharing no path, no home and no
# credential, so a session that only works because both halves happen to be one
# filesystem fails here instead of passing by coincidence. It is deliberately
# NOT part of `make test`: it wants a docker daemon and about a minute, which
# is a nightly or pre-release gate rather than an every-change one.
#
# It SKIPS, green, wherever there is no docker — so it is safe in any pipeline
# on the day the pipeline cannot yet run it.
test-remote:
go test -tags docker_e2e -count=1 -run TestRemoteTwoMachines -timeout 20m ./internal/e2e/
# THE CHAT SURFACE, ALONE. TestTUIE2E fits in forty minutes where the full
# tagged package does not (ManualOnTheWire, QuestionsE2E and the roomfeed twins
# run first and eat the budget). It used to take about seventeen, most of them
# one subtest waiting out a standing pass that went with standing orders on
# 2026-10-10. Needs OPENROUTER_API_KEY, tmux and bin/codeaf.
test-e2e-tui: build
go test -tags e2e -count=1 -timeout 40m -v -run '^TestTUIE2E$$' ./internal/e2e/
# THE WHOLE TAGGED PACKAGE. Two hours is the measured fit on Spark once every
# live-model lane is included; prefer test-e2e-tui when only the chat surface
# is under change.
test-e2e: build
go test -tags e2e -count=1 -timeout 120m -v ./internal/e2e/
# ── the demo home ───────────────────────────────────────────────────────────
#
# A HOME WITH SOMETHING ON EVERY PLACE, FOR LOOKING AT. On a machine that has
# just started using codeaf the automations store, the memory store and the
# spending ledger are empty, and every one of those pages correctly draws
# nothing — which is the emptiness law working and is also indistinguishable
# from a page that is broken. This builds a THROWAWAY home somewhere else and
# opens the real binary against it, so all of it can be seen full without a
# single invented row landing in ~/.codeaf. The window it opens starts no
# automations clock (CODEAF_NO_AUTOMATIONS), so nothing invented ever runs.
#
# It prints the directory it built and the command to open it again, so the same
# home can be returned to:
#
# make demo-home a fresh one in a temp directory
# make demo-home DEMO_HOME=/tmp/codeaf-demo build it somewhere you can name
# make demo-home DEMO_HOME=/tmp/codeaf-demo KEEP=1
# open the one already there,
# with whatever the last look left
#
# The seeder is its own binary and NOT a hidden verb on codeaf, because the
# shipped binary is on a checked-in byte budget (SIZE-BUDGET) and a developer
# target must not spend the product's weight. bin/codeaf-demo-home is not a
# second copy of the product and cannot shadow it — it is a different program
# with a different name.
DEMO_BINARY := bin/codeaf-demo-home
demo-home: build
go build -o $(DEMO_BINARY) ./cmd/codeaf-demo-home
@$(DEMO_BINARY) $(if $(DEMO_HOME),--into "$(DEMO_HOME)") $(if $(KEEP),--keep) --launch "$(CURDIR)/$(BINARY)"
# clean-run opens bin/codeaf on a fresh state root holding only your settings
# and keys (scripts/clean-run.sh), so a new build is tried from the same clean
# start every time: no conversations, projects or tasks from ~/.codeaf.
clean-run: build
@scripts/clean-run.sh
# VET ALSO COMPILES THE TESTS FOR WINDOWS. `go build` never compiles a test file,
# so a test that calls a Unix-only symbol (syscall.Mkfifo, a helper from a
# _unix.go file) breaks the Windows test build without any gate seeing it. The
# Windows pass type-checks the same packages with GOOS=windows; it costs about
# 30 seconds on a cold cache and 2 on a warm one.
vet:
go vet ./...
GOOS=windows go vet ./...
# ── the size ratchet ────────────────────────────────────────────────────────
#
# THE BINARY HAS A BUDGET AND THE BUDGET IS CHECKED IN. A megabyte of embedded
# prose, a dependency pulled in for one function, a corpus that stopped being
# packed: every one of them lands as bytes nobody measured, and the only moment
# anybody would have noticed is the moment it was added. So the number is read
# back out of the tree on every `check` and compared, which turns "the binary got
# fat somewhere over the last year" into "this change added it".
#
# IT GATES `check` AND NOT `build`. A developer iterating rebuilds twenty times
# an hour and must never be stopped by a byte count; the end-of-change ritual is
# where a size is a decision rather than an interruption.
#
# THE BUDGET IS A FILE BECAUSE A CHANGE TO IT MUST BE A DIFF. Raising it is
# allowed and sometimes right — a feature is worth its bytes — but it is a
# reviewable line in the same commit as the thing that spent them, never a flag
# somebody passed once on their own machine. PERF.md carries the same rule for
# every other cap in this repository.
#
# The measurement is bytes and not seconds, which is the doctrine PERF.md states
# for all of these gates: a work-based number is the same number on a loaded
# laptop and on idle CI, so red always means somebody changed something.
size: build
@budget=$$(cat $(BUDGET)); \
actual=$$(stat -c%s $(BINARY) 2>/dev/null || stat -f%z $(BINARY)); \
if [ "$$actual" -gt "$$budget" ]; then \
printf '\n%s is %s bytes. The budget in %s is %s. Over by %s.\n\n' \
'$(BINARY)' "$$actual" '$(BUDGET)' "$$budget" "$$((actual - budget))"; \
printf 'Shrink what you added — pack a corpus (internal/packed), drop a\n'; \
printf 'dependency, stop embedding what can be fetched — or raise the number\n'; \
printf 'in %s IN THIS COMMIT, so the extra weight is a decision somebody\n' '$(BUDGET)'; \
printf 'signed for rather than a drift nobody saw. PERF.md states the policy.\n\n'; \
printf 'The budget is set on darwin/amd64, the heaviest shipped platform, with\n'; \
printf 'its own furrow staged and the Go release go.mod pins. Every other\n'; \
printf 'platform weighs less, so red here is red there. A new Go release moves\n'; \
printf 'this number on its own; that too is a reason to reset it deliberately,\n'; \
printf 'never to ignore red.\n\n'; \
exit 1; \
fi; \
printf '%s: %s bytes, under the %s budget of %s.\n' '$(BINARY)' "$$actual" '$(BUDGET)' "$$budget"
# The end-of-change ritual in one word: prove it, then ship the binary, then
# weigh it.
# Read the release workflow so this gate follows the shipped platform list.
# The six builds share the normal GOCACHE, so a warm box pays far less than a
# cold one; the printed duration is the whole step, cold or warm.
build-cross:
@set -eu; started=$$(date +%s%N); \
targets="$$(awk '/^[[:space:]]*targets=\($$/ { in_targets=1; next } in_targets && /^[[:space:]]*\)/ { exit } in_targets { gsub(/"/, ""); if (NF == 2) print $$1 "/" $$2 }' .github/workflows/release.yml)"; \
test -n "$$targets"; \
for target in $$targets; do \
goos=$${target%/*}; goarch=$${target#*/}; \
printf 'build-cross: %s/%s\n' "$$goos" "$$goarch"; \
if ! CGO_ENABLED=0 GOOS="$$goos" GOARCH="$$goarch" go build ./...; then \
printf 'build-cross: FAILED building %s/%s\n' "$$goos" "$$goarch" >&2; exit 1; \
fi; \
done; \
finished=$$(date +%s%N); \
printf 'build-cross: total duration %ss (rounded up, an upper bound on the added time)\n' "$$(( (finished - started + 999999999) / 1000000000))"
check: vet fmt-check test test-packed-manual size build-cross
# ── the desktop app's sidecar ───────────────────────────────────────────────
#
# THE DESKTOP APP SHIPS codeaf INSIDE ITSELF, as a Tauri sidecar named for the
# Rust target triple it runs on, and that copy is built with garble so the
# bundle does not carry the program's own symbol and string table in the clear.
# It is the release build otherwise: the packed manual, -trimpath, the same
# stamp, the target's own furrow staged first, and CGO off, which is also how
# the release cross-builds darwin.
#
# GARBLE IS PINNED, and installed into bin/tools under its version, because an
# obfuscator that moves under a build is a binary nobody can reproduce. v0.18
# requires Go 1.27 and this module is on 1.26, so v0.17 is the newest that
# builds here; move both together when the module's Go line moves.
#
# A CRASH MUST BE READABLE. garble's -tiny removes everything the runtime prints,
# so a panic or a fatal error ends the engine with exit status 2 and no words at
# all, and the desktop app's log has nothing to keep. Without it a report names
# hashed functions and files; `make app-sidecar-reverse` turns those back. That
# needs the build's own seed, so each build makes one and keeps it in APP_SEED_DIR,
# named for the commit, the target and the start of the checksum of the binary as built
# (signing changes that checksum, so find it by commit). The seed is what undoes the hashing:
# it goes in neither the repository nor anything that ships.
#
# make app-sidecar APP_GOOS=darwin APP_GOARCH=arm64 # one triple
# make app-sidecars # all four
#
# Windows carries its pinned native furrow, just as release.yml does.
# It is built WITHOUT garble: Microsoft Defender quarantined the garbled
# binary on first run as "a virus or potentially unwanted software" (2026-10-19).
# release.yml's Windows codeaf is a plain go build too.
# It is not in app-sidecars' loop: the desktop app ships for macOS and Linux, and
# windows/amd64 exists for running the app natively on a Windows machine.
# make app-sidecar APP_GOOS=windows APP_GOARCH=amd64
GARBLE_VERSION := v0.17.0
GARBLE := bin/tools/garble-$(GARBLE_VERSION)
APP_GOOS ?= $(shell go env GOOS)
APP_GOARCH ?= $(shell go env GOARCH)
APP_DIST := dist/app
GARBLE_FLAGS := -literals
APP_SEED_DIR ?= $(HOME)/.codeaf-build-seeds
$(GARBLE):
@mkdir -p $(@D)/garble-$(GARBLE_VERSION).d
env -u GOOS -u GOARCH GOBIN=$(CURDIR)/$(@D)/garble-$(GARBLE_VERSION).d go install mvdan.cc/garble@$(GARBLE_VERSION)
mv $(@D)/garble-$(GARBLE_VERSION).d/garble $@
rmdir $(@D)/garble-$(GARBLE_VERSION).d
app-sidecar: embed $(if $(filter windows,$(APP_GOOS)),,$(GARBLE))
@set -eu; \
case "$(APP_GOOS)/$(APP_GOARCH)" in \
darwin/arm64) triple=aarch64-apple-darwin ;; \
darwin/amd64) triple=x86_64-apple-darwin ;; \
linux/amd64) triple=x86_64-unknown-linux-gnu ;; \
linux/arm64) triple=aarch64-unknown-linux-gnu ;; \
windows/amd64) triple=x86_64-pc-windows-msvc ;; \
*) printf 'app-sidecar: no desktop target for %s/%s\n' "$(APP_GOOS)" "$(APP_GOARCH)" >&2; exit 1 ;; \
esac; \
env -u GOOS -u GOARCH go run ./internal/furrowbin/cmd/fetch -goos=$(APP_GOOS) -goarch=$(APP_GOARCH); \
ext=""; seed=""; \
if [ "$(APP_GOOS)" = windows ]; then \
ext=".exe"; build="go build"; \
else \
case " $${GOFLAGS:-} " in *" -x "*|*" -n "*) \
echo 'app-sidecar: -x and -n print the seed; unset them' >&2; exit 1 ;; esac; \
seed=$$(head -c 8 /dev/urandom | base64 | tr -d '=\n'); \
build="$(GARBLE) -seed=$$seed $(GARBLE_FLAGS) build"; \
fi; \
mkdir -p $(APP_DIST); \
CGO_ENABLED=0 GOOS=$(APP_GOOS) GOARCH=$(APP_GOARCH) $$build \
-tags=$(MANUAL_TAG) -trimpath -ldflags="-s -w $(BUILD_STAMP)" \
-o $(APP_DIST)/codeaf-$$triple$$ext ./cmd/codeaf; \
if [ -n "$$seed" ]; then \
sum=$$(shasum -a 256 $(APP_DIST)/codeaf-$$triple 2>/dev/null || sha256sum $(APP_DIST)/codeaf-$$triple); \
sum=$$(printf '%s' "$$sum" | cut -c1-16); \
umask 077; mkdir -p "$(APP_SEED_DIR)"; \
printf 'seed=%s\nrev=%s\ntarget=%s/%s\n' "$$seed" "$(BUILD_REV)" "$(APP_GOOS)" "$(APP_GOARCH)" \
> "$(APP_SEED_DIR)/$(BUILD_REV)-$(APP_GOOS)-$(APP_GOARCH)-$$sum.seed"; \
printf 'app-sidecar: seed kept as %s.seed\n' "$(BUILD_REV)-$(APP_GOOS)-$(APP_GOARCH)-$$sum"; \
fi; \
printf 'app-sidecar: %s\n' "$(APP_DIST)/codeaf-$$triple$$ext"
# Reads a crash report from a shipped build, in a checkout of that build's commit:
# make app-sidecar-reverse SEED=<seed file> LOG=<file holding the report>
app-sidecar-reverse: $(GARBLE)
@seed=$$(sed -n 's/^seed=//p' "$(SEED)"); \
target=$$(sed -n 's/^target=//p' "$(SEED)"); \
CGO_ENABLED=0 GOOS=$${target%/*} GOARCH=$${target#*/} $(GARBLE) -seed=$$seed $(GARBLE_FLAGS) reverse \
-tags=$(MANUAL_TAG) ./cmd/codeaf "$(LOG)"
app-sidecars:
@for target in darwin/arm64 darwin/amd64 linux/amd64 linux/arm64; do \
$(MAKE) --no-print-directory app-sidecar APP_GOOS=$${target%/*} APP_GOARCH=$${target#*/} || exit 1; \
done
# ── the changelog ───────────────────────────────────────────────────────────
#
# ONE FILE PER PULL REQUEST, ROLLED UP WHEN A VERSION IS CUT. The entries live
# loose in docs/changes/unreleased because several sessions work this tree at
# once and a shared file that every branch appends to conflicts on every merge —
# and a step that reliably produces a conflict is a step people reliably route
# around.
#
# What the entries carry is not what shipped. It is what somebody now believes
# WRONGLY: the branch that stopped existing, the default that moved, the refusal
# that became a capability. docs/rules/changelog.md says why that is the field
# the format is built around and why it cannot be generated.
CHANGES := ./cmd/codeaf-changes
changelog-new:
@test -n "$(PR)" || { echo 'usage: make changelog-new PR=82 KIND=changed SLUG=branch-rules'; exit 1; }
@test -n "$(KIND)" || { echo 'usage: make changelog-new PR=82 KIND=changed SLUG=branch-rules'; exit 1; }
@test -n "$(SLUG)" || { echo 'usage: make changelog-new PR=82 KIND=changed SLUG=branch-rules'; exit 1; }
@go run $(CHANGES) new $(KIND) $(PR) $(SLUG)
changelog-check:
@go run $(CHANGES) check
changelog-preview:
@test -n "$(VERSION)" || { echo 'usage: make changelog-preview VERSION=v0.2.0'; exit 1; }
@go run $(CHANGES) render $(VERSION)
# Run this on a branch and land it through a pull request into `dev` BEFORE the
# promotion — never as a commit on `staging`, which would break the fast-forward
# the whole branch model rests on. docs/rules/promotion.md has the order.
changelog:
@test -n "$(VERSION)" || { echo 'usage: make changelog VERSION=v0.2.0'; exit 1; }
@go run $(CHANGES) roll $(VERSION)
# ── THE CALL CENSUS ─────────────────────────────────────────────────────────
#
# `make census` reads the model-call log this build always writes and prints
# a Markdown summary. It is the instrument that
# design's §8 asks for: five waves of recovery work each move a number in that
# table, and without a committed measurement every one of them is an argument
# about anecdotes.
#
# make census this machine's own log
# make census LOG=/path/to/calls.jsonl a log synced from somewhere else
# make census OUT=/tmp/census.md write it to a file instead of stdout
# make census TOP=40 DAYS=7 widen the signature list and the window
#
# NIGHTLY IT IS THE SAME COMMAND. The Spark's cron syncs the laptop's log and
# runs `make census LOG=… OUT=…`; bench/README.md has the recipe and the one
# thing the cron owns that this target does not.
census:
@go run ./cmd/codeaf-census \
$(if $(TOP),-top $(TOP)) $(if $(DAYS),-days $(DAYS)) \
$(if $(MIN),-min $(MIN)) $(if $(CHAINS),-chains $(CHAINS)) \
$(LOG) $(if $(OUT),> $(OUT))
@$(if $(OUT),echo "the census is in $(OUT)")
# ── THE REPLAY ──────────────────────────────────────────────────────────────
#
# `make replay` judges a chooser the only way a chooser can honestly be judged:
# by the regret it would have paid over ten days of the model-call log this build
# already writes. Every change to the machine chooser in the week to 2026-09-11
# was argued from a screenshot and a ten-row grep; this is the instrument that
# ends that, and a change is required
# to cite it.
#
# make replay this machine's own log and journal
# make replay LOG=/path/calls.jsonl SIGHTINGS=/path/lanes.log
# make replay SINCE=2026-09-11T14: one afternoon, for an incident
# make replay OUT=/tmp/replay.md write it to a file instead of stdout
# make replay WINDOW=30m INCIDENTS=10 widen the estimator, show more moments
#
# It reads only; it never writes a belief file and never opens a connection.
replay:
@go run ./cmd/codeaf-replay \
$(if $(LOG),-log $(LOG)) $(if $(SIGHTINGS),-sightings $(SIGHTINGS)) \
$(if $(SINCE),-since $(SINCE)) $(if $(WINDOW),-window $(WINDOW)) \
$(if $(MIN),-min $(MIN)) $(if $(INCIDENTS),-incidents $(INCIDENTS)) \
$(if $(OUT),-out $(OUT))
clean:
rm -rf bin