# Backend battery as one command. `make battery` IS the manual battery every session used to retype; # it is the entry point CI will call, so what runs locally and what runs there cannot drift. # # Run from backend/. Toolchain and linter are PINNED here (never "latest"): the linter's findings are a # gate, and a gate that changes under you on someone else's machine is not a gate. # THIS_MAKEFILE is captured FIRST, before anything could `include`: $(lastword $(MAKEFILE_LIST)) names this # file only until the first include, after which it names the included one. realpath, not abspath, because # abspath does not resolve symlinks — a Makefile symlinked from the repo root would have anchored the copy # below at the root and swept the whole repository, .env included. THIS_MAKEFILE := $(realpath $(lastword $(MAKEFILE_LIST))) GO ?= go GO_MIN_VERSION := 1.26.4 GOLANGCI_LINT ?= golangci-lint GOLANGCI_VERSION := 2.12.2 # backend/bin/ is already gitignored, so the vet tool leaves no untracked file behind. TMVET := bin/tmvet .PHONY: build vet fmt lint test mutations battery battery-stand tools-check build: tools-check $(GO) build ./... # `-vettool` REPLACES the standard vet suite rather than adding to it (measured: a copylocks finding # that plain `go vet` reports disappears under -vettool), so both passes have to run. Both tag sets too: # the paid `live` tests compile only under -tags live and would otherwise rot unseen. vet: $(GO) vet ./... $(GO) vet -tags live ./... $(GO) build -o $(TMVET) ./cmd/tmvet $(GO) vet -vettool=$(TMVET) ./... $(GO) vet -tags live -vettool=$(TMVET) ./... # `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; } tools-check: @$(GO) version | grep -qE 'go1\.(2[6-9]|[3-9][0-9])' || { \ echo "Go $(GO_MIN_VERSION)+ required (go.mod floor); got: $$($(GO) version)"; exit 1; } @$(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; } lint: tools-check $(GOLANGCI_LINT) run --timeout=15m ./... # -race needs cgo (a C toolchain). If it is missing this fails loudly — dropping -race would turn a # missing toolchain into a green run that proved less than it claims. # # The PER-PACKAGE timeout is stated rather than left at Go's 10-minute default, because under the load # this project treats as normal the default is not far enough away to be one. `internal/pipeline` under # -race has been measured from ~95 s on a quiet host to ~560 s with three parallel agent sessions # sharing it — a 6x spread on load alone — and past the default the whole battery ends in a per-package # timeout that looks like a failure and is not one (it has done so twice). Raising the ceiling weakens # no assertion: the same tests run, to completion. A package that genuinely hangs still fails, twenty # minutes later. test: $(GO) test ./... -race -count=1 -timeout=20m # The mutation gate: the catalogue's battery subset, planted and run against a COPY of this tree. # # WHY IT EXISTS. The catalogue proved that a mutation EXISTS, not that anything catches it (backlog row # 313): 211 of its entries were verified only by grepping for their anchor, so a green catalogue read as # "the pin holds" while saying "the planting still applies". Running all of it is not the answer: # 137 of the 225 entries drive internal/pipeline, which is ~16 s each WITHOUT -race (the tool never # passes it), so the full sweep is ~40 min of arithmetic, not the six hours row 313 records — that figure is the same count at # the battery's -race cost. Forty minutes is still not something a session runs per edit — so a named SUBSET # runs here, and the subset's composition lives in the catalogue's own `battery` flag. Listing ids in this file would be a # second carrier of that composition, and the next entry added would fall outside the gate silently. # # ⚠ THE COPY IS THE CONTRACT, NOT A PRECAUTION. tmmutate EDITS the sources it plants in. Pointed at the # working tree, a hard kill leaves a planting behind — that has happened, and an independent reviewer took # measurements off the poisoned tree and reported a defect that did not exist. The copy is of the WORKING # TREE rather than of HEAD, because a gate that judged HEAD would not see the pins the current change adds. # It is removed on the failing path too (trap), or /tmp fills up one battery at a time. # # ⚠ THE COPY IS MADE WITHOUT .env, NOT CLEANED OF IT AFTERWARDS. `cp -a` then `rm -f` leaves a window in # which the keys are on disk outside the repository, and a SIGKILL inside that window is not covered by the # trap; tar excludes the file before it is ever written. # # ⚠ TWO STEPS WITH &&, NOT A PIPE. A shell pipeline reports the LAST command's status, so `tar -cf - | tar # -x` walks on after a source that failed halfway: the copy is then a TRUNCATED tree, the package still # compiles without one test file, and the gate prints SURVIVED about production code — a false defect, which # the header of cmd/tmmutate records as having already cost this project one report. The named trade: a # parallel edit during the archive step now fails the gate instead of yielding a half copy. bin/ is excluded # because nothing here runs the vet tool and it is 9.7 MB of the 17. # # ⚠ AND THE SOURCE IS ANCHORED TO THIS FILE'S OWN DIRECTORY, not to $(CURDIR). CURDIR is where make was # INVOKED, so `make -f backend/Makefile mutations` from the repo root tarred the whole repository — and # the exclude is anchored to the archive root, so it matched nothing and the repository's .env files went in. # Measured: from the repo root the CURDIR form archived 61023 members carrying TWO .env — backend/.env AND # eval/.env, i.e. the leak crossed into another zone's keys; this form archives 488 and zero. (The rest of the recipe still assumes # backend/ is the working directory — `go run ./cmd/tmmutate` fails loudly elsewhere — but a loud failure # must not be preceded by writing keys to /tmp.) MAKEFILE_DIR := $(patsubst %/,%,$(dir $(THIS_MAKEFILE))) mutations: @tmp=$$(mktemp -d) && trap 'rm -rf "$$tmp"' EXIT && trap 'rm -rf "$$tmp"; exit 130' INT TERM && \ mkdir "$$tmp/backend" && \ tar -C "$(MAKEFILE_DIR)" -cf "$$tmp/tree.tar" --exclude='./.env*' --exclude=./bin . && \ tar -C "$$tmp/backend" -xf "$$tmp/tree.tar" && rm -f "$$tmp/tree.tar" && \ $(GO) run ./cmd/tmmutate -root "$$tmp/backend" -battery # The hermetic battery: everything that is green on a bare clone. It ends by NAMING the tests that did # not run, because a skip is invisible in `ok` lines and a silent skip reads as coverage. # The harvest log goes to a TEMP file, not to .skips.log in the tree. Both normal paths removed the # in-tree file, but Ctrl-C during the harvest pass left it behind, and it is not gitignored: the next # `git status` then shows a position that is not work, which is how untracked files get swept into a # commit or, worse, make a session miss the ones that ARE work. Recorded as debt on 09.08 # (13-tech-debt-anchors.md, Б-122) and closed here. mktemp+trap is the same idiom as `mutations` above. # # The INT/TERM trap EXITS; it does not just clean up. A POSIX shell resumes the interrupted line after its # handler returns, so a cleanup-only trap left the chain running with the log already deleted — and this # recipe's last step then printed "(none)", i.e. NO SKIPS, off a harvest that never finished. A silent skip # reading as coverage is the exact failure this pass exists to catch. battery: build vet fmt lint test @echo "--- did NOT run (no stand data; see battery-stand) ---" @log=$$(mktemp) && trap 'rm -f "$$log"' EXIT && trap 'rm -f "$$log"; exit 130' INT TERM && \ { $(GO) test ./... -count=1 -v > "$$log" 2>&1 || { echo "the skip-harvest pass FAILED:"; \ grep -E '^(---|\s+---) FAIL|^FAIL' "$$log"; exit 1; }; } && \ { grep -- '--- SKIP' "$$log" || echo "(none)"; } # The stand battery: adds the corpus-gated tests. With the flags set, MISSING data fails instead of # skipping, so "the corpus is here" is asserted rather than assumed. battery-stand: build vet fmt lint TM_MINER_PARITY=1 TM_CHECKER_LABELS=1 $(GO) test ./... -race -count=1 -timeout=20m