textmachine/platform/Makefile

213 lines
14 KiB
Makefile

# Platform battery as one command: `make check` is what CI calls and what a session runs before
# handing the tree over. Toolchain and linter are PINNED (never "latest"): a gate that changes
# under you on someone else's machine is not a gate. Shape mirrors backend/Makefile deliberately.
GO ?= go
# go.mod's floor is 1.26.4 (the engine's), but the BUILD toolchain floor here is HIGHER, and for one
# reason: this module is the one exposed to the network, so it takes the standard library's security
# fixes as soon as they are released.
#
# ⚠ Raised 1.26.5 → 1.26.6 on 13.08 by `make vuln`, not by choice: the advisory database published
# five standard-library vulnerabilities against 1.26.5 — net/http, crypto/tls, net/url, encoding/xml,
# encoding/asn1 (GO-2026-6218/6090/6089/6088/5972) — all fixed in 1.26.6, and govulncheck traces two
# of them into paths this service calls. The same battery is green on 1.26.6 and the scan is clean.
# HOW the floor is actually enforced, because the first version of this raise enforced nothing: the
# check below COMPARES versions (its predecessor was a regex that matched the very 1.26.5 it was
# written to refuse), and go.mod carries `toolchain go1.26.6`, which every build reads whether or not
# it calls make — with GOTOOLCHAIN=auto such a host fetches this toolchain, with =local it stops.
# The comparison itself is pinned in internal/gates.
GO_MIN_VERSION := 1.26.6
GOLANGCI_LINT ?= golangci-lint
GOLANGCI_VERSION := 2.12.2
# sqlc generates the query layer of the glue-free block of internal/pgstore. Pinned EXACTLY, like the
# linter above and for a sharper reason: the generator's output is committed to the tree, so a
# different version silently produces a different diff and `sqlc diff` then fails on a clean checkout.
# Release 22.04.2026, checked live against the vendor rather than remembered.
SQLC ?= sqlc
SQLC_VERSION := 1.31.1
.PHONY: build vet fmt lint test check conditions report-failures tools-check version-check vuln fuzz sqlc-check sqlc-generate
build: tools-check
$(GO) build ./...
vet:
$(GO) vet ./...
# `gofmt -l` exits 0 even when it names files, so the emptiness of its output is the assertion.
fmt:
@test -z "$$(gofmt -l .)" || { echo "gofmt: not formatted:"; gofmt -l .; exit 1; }
# GO_VERSION is what the gate JUDGES, and it is a variable so the judgement can be tested with
# versions this host does not have: `make version-check GO_VERSION=go1.26.5` must fail.
GO_VERSION ?= $$($(GO) env GOVERSION)
# version-check COMPARES rather than matches. The predecessor of this rule was a regex over
# `go version`, and it accepted the very release it was written to refuse — 1.26.5 matched
# `go1\.26\.([5-9]|…)` — while GO_MIN_VERSION lived only in the failure message. A comparison has no
# such gap: `sort -V` orders 1.26.10 above 1.26.9, which a regex over digits gets wrong too, and it
# is the same tool the release engineering of this repo already relies on.
version-check:
@have="$$(printf '%s' "$(GO_VERSION)" | sed 's/^go//')"; \
case "$$have" in *[!0-9.]*) \
echo "Go $(GO_MIN_VERSION)+ required, and a release: a prerelease or development toolchain does not carry the fixes its number promises; got: go$$have"; \
exit 1;; esac; \
lowest="$$(printf '%s\n%s\n' "$$have" "$(GO_MIN_VERSION)" | sort -V | head -1)"; \
test "$$lowest" = "$(GO_MIN_VERSION)" || { \
echo "Go $(GO_MIN_VERSION)+ required (standard-library security fixes in a network-facing module); got: go$$have"; \
exit 1; }
tools-check: version-check
@$(GOLANGCI_LINT) --version 2>/dev/null | grep -q " $(GOLANGCI_VERSION) " || { \
echo "golangci-lint $(GOLANGCI_VERSION) required (findings are version-dependent)."; \
echo "install: https://github.com/golangci/golangci-lint/releases/tag/v$(GOLANGCI_VERSION)"; exit 1; }
@test "$$($(SQLC) version 2>/dev/null)" = "v$(SQLC_VERSION)" || { \
echo "sqlc v$(SQLC_VERSION) required (the generated query layer is committed, so the version decides the diff); got: $$($(SQLC) version 2>/dev/null || echo none)"; \
echo "install: go install github.com/sqlc-dev/sqlc/cmd/sqlc@v$(SQLC_VERSION) (and put $$($(GO) env GOPATH)/bin on PATH: 'got: none' with the binary on disk is a PATH problem, not a missing install)"; exit 1; }
# sqlc-check is the AUTHORITATIVE freshness gate: it re-runs the generator and fails if what is in the
# tree is not what it produces. It is a prerequisite of `check` rather than a target somebody has to
# remember, because a target `make check` does not call is not a gate.
#
# The battery carries a SECOND, weaker check of the same property that needs no tool
# (pgstore.TestEveryGeneratedQueryMatchesItsSourceFile): this one proves the whole generated file,
# that one proves the SQL text, and only the second survives on a host without sqlc.
sqlc-check: tools-check
$(SQLC) diff
sqlc-generate: tools-check
$(SQLC) generate
lint: tools-check
$(GOLANGCI_LINT) run --timeout=10m ./...
# -race needs cgo. If the C toolchain is missing this fails loudly rather than quietly proving less.
test:
$(GO) test ./... -race -count=1
# The host conditions the battery reads, each with whether THIS host meets it and which packages it
# opens. DERIVED from the test sources, not listed here: a list kept by hand is a second carrier of a
# fact the tests already carry, and the two drift in the direction nobody is watching (PD-374,
# PD-432) — a hint that names one condition of several sends the reader to a knob that is already on,
# and the skips that remain read as the zone's normal. Three shapes are derived, each anchored on the
# CALL a test helper makes rather than on a name in prose: `os.Getenv("TM_PLATFORM_TEST_…")` and its
# `os.LookupEnv` twin for the environment, `exec.LookPath("…")` for a binary the host must carry, and
# `systemdOrSkip`'s own systemctl probe. A condition a new test starts reading appears here on its
# own; the gate in internal/gates holds this target to that — and it derives the same facts by PARSING
# the sources rather than by grepping them, so the two disagree the moment these anchors stop matching
# what the tests actually do.
conditions:
@for v in $$(grep -rhoE '(Getenv|LookupEnv)\("TM_PLATFORM_TEST_[A-Z_]+"\)' --include='*_test.go' . | grep -oE 'TM_PLATFORM_TEST_[A-Z_]+' | sort -u); do \
if [ -n "$$(printenv "$$v")" ]; then state=set; else state=UNSET; fi; \
pkgs=$$(grep -rlE "(Getenv|LookupEnv)\(\"$$v\"\)" --include='*_test.go' . | xargs -n1 dirname | sort -u | sed 's#^\./##' | tr '\n' ' '); \
printf ' %-34s %-11s read by: %s\n' "$$v" "$$state" "$$pkgs"; done; \
for b in $$(grep -rhoE 'LookPath\("[A-Za-z0-9_.-]+"\)' --include='*_test.go' . | sed 's/LookPath("//; s/")//' | sort -u); do \
if command -v "$$b" >/dev/null 2>&1; then state=present; else state=MISSING; fi; \
pkgs=$$(grep -rlE "LookPath\(\"$$b\"\)" --include='*_test.go' . | xargs -n1 dirname | sort -u | sed 's#^\./##' | tr '\n' ' '); \
printf ' %-34s %-11s read by: %s\n' "$$b (on PATH)" "$$state" "$$pkgs"; done; \
if systemctl --user show --property=Version >/dev/null 2>&1; then state=reachable; else state=UNREACHABLE; fi; \
pkgs=$$(grep -rl 'systemdOrSkip(t)' --include='*_test.go' . | xargs -n1 dirname | sort -u | sed 's#^\./##' | tr '\n' ' '); \
printf ' %-34s %-11s read by: %s(systemdOrSkip: a reachable user systemd manager)\n' "systemctl --user" "$$state" "$$pkgs"; \
printf ' %-34s %-11s %s\n' "CREATEDB for the DSN's role" "unprobed" "asked only once a scratch database is created; the skip says so in words"
# The battery. One verbose run under -race serves both purposes (PD-17: it used to run the suite a
# second time without -race just to harvest skip names), and it NAMES the tests that did not run —
# under the host conditions that gate them (`conditions` above): a silent skip reads as coverage, and
# a hint that names one condition of several sends the reader to a knob that is already on.
# It also prints the register gate's findings (internal/gates, `ALARM` lines): open rows whose weight
# says minor and whose words say money, silence or a hold — the class a reconnaissance that counts
# only `major` never sees.
#
# ⛔ THE LOG IS PER-RUN AND ITS ABSENCE IS RED, and both halves were bought by a defect. Every line
# this recipe prints is a GREP over that file, and grep answers a missing file exactly as it answers a
# clean one: with nothing. So the recipe used to reach its `else` and print «every test ran: no host
# condition was missing» over a run whose log had vanished — a bill of health for a measurement that
# never happened. Observed 06.09: three `grep: .check.log: No such file or directory` followed by that
# very line, on a run with FIVE skips.
#
# The cause was the FIXED name: two batteries in one directory — normal here, the zone and the
# orchestrator both run it — and whichever finished first deleted the other's evidence mid-recipe.
# `$$$$` is the shell's PID, so runs no longer share a file. The guard below is the second half and
# the one that matters: it covers every OTHER way a log can go missing (a failed redirect, a full
# disk, a hand), and it turns «nothing to read» into a failure instead of a clean bill. ⚠ The two are
# not redundant — the unique name removes today's cause, the guard removes the CLASS.
#
# ⛔ AND THE SWEEP IS THE PRICE OF THE PER-RUN NAME, not tidiness. The fixed name limited itself: a
# failed run left ONE stale file and the next run wrote over it. Per-run names removed that property
# along with the collision, so every failed or interrupted run leaves its log behind for good — four
# of them accumulated in one night. They are gitignored, so nothing reaches a commit; what rots is
# READABILITY, and precisely where it is load-bearing: this recipe KEEPS the log on failure on
# purpose, and a heap of debris from interrupted runs is indistinguishable from the one file somebody
# was told to read.
#
# ⚠ IT SWEEPS ONLY LOGS WHOSE WRITER IS GONE (`kill -0`, which is POSIX and needs no `/proc`), and
# that condition is the whole of it: a bare `rm -f .check.log.*` would delete a CONCURRENT run's log
# and restore the very defect the per-run name was introduced to remove. A recycled PID keeps a stale
# file — the conservative direction, and the only one that cannot destroy evidence. ⚠ Consequence
# worth knowing rather than discovering: the log kept by a FAILED run is swept by the NEXT run, so
# read it before re-running.
check: build vet fmt lint sqlc-check
@for f in .check.log.*; do [ -e "$$f" ] || continue; p=$${f##*.}; \
case "$$p" in ''|*[!0-9]*) continue;; esac; \
kill -0 "$$p" 2>/dev/null || rm -f "$$f"; done; \
log=.check.log.$$$$; \
$(GO) test ./... -race -count=1 -v > "$$log" 2>&1; status=$$?; \
if [ ! -s "$$log" ]; then \
echo "--- THE BATTERY LEFT NO LOG ($$log): every line below would be a grep over a file that is not there ---"; \
echo "--- and a missing file is INDISTINGUISHABLE from a clean one to grep, so this exits RED rather than printing a bill of health nobody measured ---"; \
exit 1; fi; \
grep -E '^(ok|FAIL|\?)' "$$log" || true; \
if grep -q 'ALARM PD-' "$$log"; then \
echo "--- open register rows below major that carry alarm markers (internal/gates) ---"; \
grep -o 'ALARM PD-.*' "$$log"; fi; \
if [ $$status -ne 0 ]; then \
$(MAKE) --no-print-directory -s report-failures LOG="$$log" STATUS=$$status; \
echo "--- the log is kept at $$log: the message under a failing test is where it says what to do ---"; \
exit 1; fi; \
if grep -q -- '--- SKIP' "$$log"; then \
echo "--- did NOT run: $$(grep -c -- '--- SKIP' "$$log") skipped. Host conditions the battery reads (from the test sources), each with what it opens: ---"; \
$(MAKE) --no-print-directory -s conditions; \
echo "--- skipped tests ---"; \
grep -- '--- SKIP' "$$log"; \
else echo "--- every test ran: no host condition was missing ---"; fi; \
rm -f "$$log"
# report-failures says what went wrong in a battery log. A target of its own so the reporting can be
# run against a log without running a battery, which is how `internal/gates` proves it.
#
# "go test exited non-zero" and "a test failed" are different events. A package that hits its timeout
# writes `FAIL <pkg> <seconds>` and no `--- FAIL:` anywhere, so a report built from that grep alone
# printed a header and nothing under it (backlog row 305, D39.207). A build error, a panic and a
# TestMain that cannot start share the shape.
#
# So the package lines come first and unconditionally — `^FAIL<tab>` names the package in all of those
# endings. The per-test lines follow when there are any; when there are none that is said, with its
# count, and the log's own evidence is printed under it.
#
# The evidence is capped and the cap announces the full count: a truncated grep otherwise reads as an
# absence of matches (ENGINEERING_STANDARDS §3.8).
report-failures:
@log="$(LOG)"; test -n "$$log" || { echo "report-failures needs LOG=<battery log>"; exit 2; }; \
test -s "$$log" || { echo "--- report-failures was given no log ($$log): there is nothing to read, and a missing file answers grep exactly as a clean one does ---"; exit 2; }; \
ev='test timed out|^panic:|^fatal error:|\[build failed\]|\[setup failed\]|^[^ ]*\.go:[0-9]+:|signal: '; \
named=$$(grep -cE '^(---|[[:space:]]+---) FAIL' "$$log" || true); \
echo "--- FAILURES ---"; \
grep -E '^FAIL[[:space:]]' "$$log" || echo "(no package summary line at all: the run did not get as far as naming one)"; \
if [ "$$named" -gt 0 ]; then \
grep -E '^(---|[[:space:]]+---) FAIL' "$$log"; \
else \
found=$$(grep -cE "$$ev" "$$log" || true); \
echo "--- NO TEST REPORTED A FAILURE ($$named lines of '--- FAIL'), so go test exited $(STATUS) for another reason: a package timeout, a panic, a build error, or a TestMain that could not start. Evidence, $$found lines, first 20: ---"; \
grep -E "$$ev" "$$log" | head -20; \
grep -A4 'test timed out' "$$log" | grep -E '^[[:space:]]+[A-Za-z0-9_/.]+ \([0-9]' | sort -u; \
fi
# Not in `check`: fuzzing is time-boxed exploration, not a gate. The seed corpus runs as an
# ordinary test on every `check`; this target is for going deeper on the decoder.
fuzz:
$(GO) test ./internal/ingest/ -run FuzzDecoder -fuzz FuzzDecoder -fuzztime 2m
# Not part of `check`: it needs the network (the vulnerability database), and the battery must be
# green on a bare clone offline. CI runs it as its own step (STACK_DECISIONS §5).
vuln:
$(GO) run golang.org/x/vuln/cmd/govulncheck@v1.6.0 ./...