Add CI (.amadan/ci.d + Makefile) and mechanical guards for AGENTS.md rules
Sets up amadan CI for the first time on this repo, following amadan's own convention exactly: five .amadan/ci.d/NN-* steps, each a 3-line `exec make test-<x>`, so CI and `make test` are one definition. Order is cheapest-first: vet, gofmt (fails rather than rewrites), build, go test, then -race last (it's the slow one, and needs cgo on — the one deliberate CGO_ENABLED=0 exception, documented in the Makefile). Also turns five AGENTS.md prose rules into internal/guards, a Go test package: the 300-line file cap, the ban on cgo SQLite drivers (glebarez/*, gorm.io/driver/sqlite), excelize confined to internal/sheets/xlsx, calc isolated from store/internal/sheets, and sealtest confined to test-only code. Per CARLOS's rule, a guard a shell step can satisfy is a guard a shell step can be wrong about. .amadan/timeout is 15m: measured `make test` end-to-end at 1m37s locally (store ~35s, xlsx ~5s, -race ~62-68s), so 15m leaves headroom for a colder cache or slower CI box without masking a real hang. Verified: full `make test` green, each .amadan/ci.d step run standalone green, and each guard deliberately broken and watched fail (a 305-line file, a stray excelize/store/sealtest/glebarez import, an unformatted file) before reverting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01APtNStXDZ5H2fAc1ZuGqtS
13 files changed,
+412
−0
.amadan/ci.d/10-vet+8 −0.amadan/ci.d/20-fmt+8 −0.amadan/ci.d/30-build+8 −0.amadan/ci.d/40-go-test+8 −0.amadan/ci.d/50-race+8 −0.amadan/timeout+1 −0Makefile+60 −0internal/guards/calc_isolation_test.go+39 −0internal/guards/excelize_confinement_test.go+31 −0internal/guards/forbidden_imports_test.go+29 −0internal/guards/linecap_test.go+56 −0internal/guards/repo_test.go+127 −0internal/guards/sealtest_containment_test.go+29 −0
Intent
diff --git a/.amadan/ci.d/10-vet b/.amadan/ci.d/10-vet| new file mode 100755 |
| index 0000000..a0e9ffc |
| --- /dev/null |
| +++ b/.amadan/ci.d/10-vet |
| @@ -0,0 +1,8 @@ |
| +#!/bin/sh |
| +# One step of Sheets' CI. It runs a Makefile target rather than its own |
| +# copy of the commands, so CI and the local gate stay ONE definition: |
| +# `make test` runs these same five targets in this same order, and |
| +# `make test-vet` is exactly what CI runs here. Debugging CI is running |
| +# it. |
| +set -eu |
| +exec make test-vet |
diff --git a/.amadan/ci.d/20-fmt b/.amadan/ci.d/20-fmt| new file mode 100755 |
| index 0000000..beffc37 |
| --- /dev/null |
| +++ b/.amadan/ci.d/20-fmt |
| @@ -0,0 +1,8 @@ |
| +#!/bin/sh |
| +# One step of Sheets' CI. It runs a Makefile target rather than its own |
| +# copy of the commands, so CI and the local gate stay ONE definition: |
| +# `make test` runs these same five targets in this same order, and |
| +# `make test-fmt` is exactly what CI runs here. Debugging CI is running |
| +# it. |
| +set -eu |
| +exec make test-fmt |
diff --git a/.amadan/ci.d/30-build b/.amadan/ci.d/30-build| new file mode 100755 |
| index 0000000..e2ca72c |
| --- /dev/null |
| +++ b/.amadan/ci.d/30-build |
| @@ -0,0 +1,8 @@ |
| +#!/bin/sh |
| +# One step of Sheets' CI. It runs a Makefile target rather than its own |
| +# copy of the commands, so CI and the local gate stay ONE definition: |
| +# `make test` runs these same five targets in this same order, and |
| +# `make test-build` is exactly what CI runs here. Debugging CI is |
| +# running it. |
| +set -eu |
| +exec make test-build |
diff --git a/.amadan/ci.d/40-go-test b/.amadan/ci.d/40-go-test| new file mode 100755 |
| index 0000000..b3a8e09 |
| --- /dev/null |
| +++ b/.amadan/ci.d/40-go-test |
| @@ -0,0 +1,8 @@ |
| +#!/bin/sh |
| +# One step of Sheets' CI. It runs a Makefile target rather than its own |
| +# copy of the commands, so CI and the local gate stay ONE definition: |
| +# `make test` runs these same five targets in this same order, and |
| +# `make test-go` is exactly what CI runs here. Debugging CI is running |
| +# it. |
| +set -eu |
| +exec make test-go |
diff --git a/.amadan/ci.d/50-race b/.amadan/ci.d/50-race| new file mode 100755 |
| index 0000000..974ef28 |
| --- /dev/null |
| +++ b/.amadan/ci.d/50-race |
| @@ -0,0 +1,8 @@ |
| +#!/bin/sh |
| +# One step of Sheets' CI. It runs a Makefile target rather than its own |
| +# copy of the commands, so CI and the local gate stay ONE definition: |
| +# `make test` runs these same five targets in this same order, and |
| +# `make test-race` is exactly what CI runs here. Debugging CI is running |
| +# it. |
| +set -eu |
| +exec make test-race |
diff --git a/.amadan/timeout b/.amadan/timeout| new file mode 100644 |
| index 0000000..960e1d6 |
| --- /dev/null |
| +++ b/.amadan/timeout |
| @@ -0,0 +1 @@ |
| +15m |
diff --git a/Makefile b/Makefile| new file mode 100644 |
| index 0000000..b23b5ee |
| --- /dev/null |
| +++ b/Makefile |
| @@ -0,0 +1,60 @@ |
| +.PHONY: build test test-vet test-fmt test-build test-go test-race |
| + |
| +# One binary, CGO_ENABLED=0, cgo-free SQLite (AGENTS.md / Deploys). Set |
| +# per-target below rather than exported globally, because test-race is |
| +# a deliberate, documented exception — see its comment. |
| +build: |
| + CGO_ENABLED=0 go build ./... |
| + |
| +# The gate, split into named targets so CI can report them individually |
| +# (.amadan/ci.d calls these one at a time, each its own step). `make |
| +# test` runs exactly the same five things in the same order, so the |
| +# local gate and CI stay ONE definition rather than two lists that |
| +# drift apart. Debugging CI is running `make test`. |
| +# |
| +# Order is cheapest-and-fastest first: vet and gofmt are near-instant, |
| +# build catches compile errors before minutes are spent on tests, plain |
| +# `go test` (tens of seconds, dominated by the store package) comes |
| +# next, and -race — by far the slowest step — runs last. |
| +test: test-vet test-fmt test-build test-go test-race |
| + |
| +test-vet: |
| + CGO_ENABLED=0 go vet ./... |
| + |
| +# Fails on unformatted files rather than rewriting them. CI must never |
| +# silently "fix" a diff out from under a branch — a red test-fmt tells |
| +# the author to run `gofmt -w` themselves. |
| +test-fmt: |
| + @if [ -n "$$(gofmt -l .)" ]; then \ |
| + echo "gofmt needs to be run on:"; \ |
| + gofmt -l .; \ |
| + exit 1; \ |
| + fi |
| + |
| +test-build: |
| + CGO_ENABLED=0 go build ./... |
| + |
| +# Includes internal/guards: the mechanical guards for the line cap, |
| +# forbidden imports, excelize confinement, calc isolation and sealtest |
| +# containment all run here as ordinary tests, plus internal/sheets/seal |
| +# and internal/sheets/xlsx's plaintext-leak checks (AGENTS.md / "The one |
| +# rule"). -count=1 so a cached "ok" from a previous run can never stand |
| +# in for actually having run. |
| +test-go: |
| + CGO_ENABLED=0 go test -count=1 ./... |
| + |
| +# The concurrency-bearing package: the one-connection writer pool and |
| +# its handle gate (AGENTS.md: "Scope tx, never the outer handle, inside |
| +# transactions, or the one-connection writer pool deadlocks"). Scoped to |
| +# store rather than ./... because -race's slowdown is worth paying where |
| +# the concurrency actually lives, not repeated across packages that |
| +# don't touch goroutines. |
| +# |
| +# Deliberately NOT CGO_ENABLED=0: the race detector requires cgo (`go: |
| +# -race requires cgo`), so this one target runs with the ambient |
| +# CGO_ENABLED (cgo on, given a C toolchain is present). Nothing this |
| +# target builds is shipped — it only exercises store's own test suite — |
| +# so it does not violate the cgo-free binary rule, which is about what |
| +# gets deployed. |
| +test-race: |
| + go test -race -count=1 ./internal/sheets/store/... |
diff --git a/internal/guards/calc_isolation_test.go b/internal/guards/calc_isolation_test.go| new file mode 100644 |
| index 0000000..1e4ea45 |
| --- /dev/null |
| +++ b/internal/guards/calc_isolation_test.go |
| @@ -0,0 +1,39 @@ |
| +package guards |
| + |
| +import ( |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +// calcHome is the formula engine's package. |
| +const calcHome = "internal/sheets/calc" |
| + |
| +// calcForbiddenImports are import paths calc must never reach for: the |
| +// storage layer, and the app package (internal/sheets) that wires it to |
| +// storage and HTTP. calc is the pure formula engine — parsing, the |
| +// dependency graph, evaluation — and importing either would mean it is |
| +// no longer pure. |
| +var calcForbiddenImports = []string{ |
| + "amadan.net/oficina/sheets/internal/sheets/store", |
| + "amadan.net/oficina/sheets/internal/sheets", |
| +} |
| + |
| +// TestCalcIsolatedFromStoreAndSheets fails on any file in |
| +// internal/sheets/calc that imports internal/sheets/store or |
| +// internal/sheets itself. |
| +func TestCalcIsolatedFromStoreAndSheets(t *testing.T) { |
| + root := repoRoot(t) |
| + for _, path := range goFiles(t, root) { |
| + rel := relPath(t, root, path) |
| + if filepath.ToSlash(filepath.Dir(rel)) != calcHome { |
| + continue |
| + } |
| + for _, imp := range fileImports(t, path) { |
| + for _, forbidden := range calcForbiddenImports { |
| + if imp == forbidden { |
| + t.Errorf("%s: imports %q, forbidden in %s (calc isolation)", rel, imp, calcHome) |
| + } |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/guards/excelize_confinement_test.go b/internal/guards/excelize_confinement_test.go| new file mode 100644 |
| index 0000000..427b4ec |
| --- /dev/null |
| +++ b/internal/guards/excelize_confinement_test.go |
| @@ -0,0 +1,31 @@ |
| +package guards |
| + |
| +import ( |
| + "path/filepath" |
| + "testing" |
| +) |
| + |
| +// excelizeImport is the XLSX library's import path. |
| +const excelizeImport = "github.com/xuri/excelize/v2" |
| + |
| +// excelizeHome is the only package allowed to import it. Confining |
| +// excelize's quirks to one package keeps the XLSX format's weirdness |
| +// from leaking into the rest of Sheets. |
| +const excelizeHome = "internal/sheets/xlsx" |
| + |
| +// TestExcelizeConfinedToXLSXPackage fails on any excelize import outside |
| +// internal/sheets/xlsx. |
| +func TestExcelizeConfinedToXLSXPackage(t *testing.T) { |
| + root := repoRoot(t) |
| + for _, path := range goFiles(t, root) { |
| + rel := relPath(t, root, path) |
| + if filepath.ToSlash(filepath.Dir(rel)) == excelizeHome { |
| + continue |
| + } |
| + for _, imp := range fileImports(t, path) { |
| + if imp == excelizeImport { |
| + t.Errorf("%s: imports %q, confined to %s (excelize confinement)", rel, excelizeImport, excelizeHome) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/guards/forbidden_imports_test.go b/internal/guards/forbidden_imports_test.go| new file mode 100644 |
| index 0000000..ebb6af6 |
| --- /dev/null |
| +++ b/internal/guards/forbidden_imports_test.go |
| @@ -0,0 +1,29 @@ |
| +package guards |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// cgoSQLiteDriverForbidden reports whether imp is a cgo SQLite driver. |
| +// The stack is cgo-free by design (AGENTS.md / Deploys: "One binary, |
| +// CGO_ENABLED=0, cgo-free SQLite. A cgo driver sneaking in is a bug."), |
| +// so either of these landing in go.mod's dependency graph is a bug, not |
| +// a style preference. |
| +func cgoSQLiteDriverForbidden(imp string) bool { |
| + return strings.HasPrefix(imp, "github.com/glebarez/") || imp == "gorm.io/driver/sqlite" |
| +} |
| + |
| +// TestNoCgoSQLiteDriver walks every .go file's import block and fails |
| +// on any import of glebarez/* or gorm.io/driver/sqlite. |
| +func TestNoCgoSQLiteDriver(t *testing.T) { |
| + root := repoRoot(t) |
| + for _, path := range goFiles(t, root) { |
| + rel := relPath(t, root, path) |
| + for _, imp := range fileImports(t, path) { |
| + if cgoSQLiteDriverForbidden(imp) { |
| + t.Errorf("%s: imports %q, a cgo SQLite driver forbidden by AGENTS.md (Deploys: cgo-free SQLite)", rel, imp) |
| + } |
| + } |
| + } |
| +} |
diff --git a/internal/guards/linecap_test.go b/internal/guards/linecap_test.go| new file mode 100644 |
| index 0000000..ec3cc51 |
| --- /dev/null |
| +++ b/internal/guards/linecap_test.go |
| @@ -0,0 +1,56 @@ |
| +package guards |
| + |
| +import ( |
| + "bufio" |
| + "os" |
| + "testing" |
| +) |
| + |
| +// maxFileLines is AGENTS.md's per-file cap ("Mechanical rules" and the |
| +// frontend section both cite 300 lines; this test enforces the Go side). |
| +const maxFileLines = 300 |
| + |
| +// lineCapExempt lists files exempt from the cap, by exact repo-relative |
| +// path — never by pattern or directory, so an exemption is a visible, |
| +// reviewable decision rather than a rule that silently swallows |
| +// whatever lands nearby later. Empty today: nothing vendored or |
| +// generated in this repo's .go tree exceeds the cap yet. Add an entry |
| +// here, dated and justified, the day one does. |
| +var lineCapExempt = map[string]bool{} |
| + |
| +// TestFileLineCap enforces the 300-line cap per .go file. It is a floor, |
| +// not a ratchet: it stops a file from ever crossing 300 lines, but does |
| +// not push any file already under the cap any lower. |
| +func TestFileLineCap(t *testing.T) { |
| + root := repoRoot(t) |
| + for _, path := range goFiles(t, root) { |
| + rel := relPath(t, root, path) |
| + if lineCapExempt[rel] { |
| + continue |
| + } |
| + n, err := countLines(path) |
| + if err != nil { |
| + t.Fatalf("guards: count lines in %s: %v", rel, err) |
| + } |
| + if n > maxFileLines { |
| + t.Errorf("%s: %d lines exceeds the %d-line cap (AGENTS.md Mechanical rules: 300-line file cap)", rel, n, maxFileLines) |
| + } |
| + } |
| +} |
| + |
| +// countLines counts newline-terminated lines the way `wc -l` does, |
| +// which is what the 300-line figure in AGENTS.md was measured against. |
| +func countLines(path string) (int, error) { |
| + f, err := os.Open(path) |
| + if err != nil { |
| + return 0, err |
| + } |
| + defer f.Close() |
| + sc := bufio.NewScanner(f) |
| + sc.Buffer(make([]byte, 0, 64*1024), 1024*1024) |
| + n := 0 |
| + for sc.Scan() { |
| + n++ |
| + } |
| + return n, sc.Err() |
| +} |
diff --git a/internal/guards/repo_test.go b/internal/guards/repo_test.go| new file mode 100644 |
| index 0000000..6627e19 |
| --- /dev/null |
| +++ b/internal/guards/repo_test.go |
| @@ -0,0 +1,127 @@ |
| +// Package guards turns rules that used to live only as prose in |
| +// AGENTS.md into tests. CARLOS's own rule for this: "A guard a shell |
| +// step can satisfy is a guard a shell step can be wrong about — put |
| +// guards inside the test binary." So these run as part of `go test |
| +// ./...`, not as separate shell greps that a CI step could get subtly |
| +// wrong (or that a shell rewrite could quietly stop running). |
| +// |
| +// This file holds the shared plumbing (repo-root discovery, file |
| +// walking, import parsing) that every rule's test file uses. It carries |
| +// no Test functions of its own. |
| +package guards |
| + |
| +import ( |
| + "go/parser" |
| + "go/token" |
| + "os" |
| + "path/filepath" |
| + "runtime" |
| + "strconv" |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// module is the module path declared in this repo's go.mod. Used to |
| +// confirm repoRoot found the right go.mod, not some unrelated one on a |
| +// parent path (e.g. if the worktree lives under another Go module). |
| +const module = "amadan.net/oficina/sheets" |
| + |
| +// repoRoot locates the module root by walking up from this source |
| +// file's own location (via runtime.Caller), not from the working |
| +// directory. `go test ./...` runs every package's tests with that |
| +// package's own directory as cwd, so cwd is not a reliable anchor — |
| +// runtime.Caller(0) always points back to this file, wherever it was |
| +// invoked from. |
| +func repoRoot(t *testing.T) string { |
| + t.Helper() |
| + _, thisFile, _, ok := runtime.Caller(0) |
| + if !ok { |
| + t.Fatal("guards: runtime.Caller(0) failed") |
| + } |
| + dir := filepath.Dir(thisFile) |
| + for { |
| + if b, err := os.ReadFile(filepath.Join(dir, "go.mod")); err == nil { |
| + if strings.Contains(string(b), "module "+module+"\n") { |
| + return dir |
| + } |
| + } |
| + parent := filepath.Dir(dir) |
| + if parent == dir { |
| + t.Fatalf("guards: no go.mod for module %q found above %s", module, filepath.Dir(thisFile)) |
| + } |
| + dir = parent |
| + } |
| +} |
| + |
| +// skipDirs names directories goFiles never descends into: version |
| +// control, dependency/vendor trees, and dotfiles-style tooling |
| +// directories that hold no Go source of ours. |
| +func skipDir(name string) bool { |
| + if strings.HasPrefix(name, ".") { |
| + return true |
| + } |
| + switch name { |
| + case "vendor", "node_modules", "testdata": |
| + return true |
| + } |
| + return false |
| +} |
| + |
| +// goFiles returns the repo-relative path of every *.go file under root. |
| +func goFiles(t *testing.T, root string) []string { |
| + t.Helper() |
| + var files []string |
| + err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { |
| + if err != nil { |
| + return err |
| + } |
| + if d.IsDir() { |
| + if path != root && skipDir(d.Name()) { |
| + return filepath.SkipDir |
| + } |
| + return nil |
| + } |
| + if strings.HasSuffix(path, ".go") { |
| + files = append(files, path) |
| + } |
| + return nil |
| + }) |
| + if err != nil { |
| + t.Fatalf("guards: walk %s: %v", root, err) |
| + } |
| + return files |
| +} |
| + |
| +// relPath is filepath.Rel with the guards-package error-handling |
| +// boilerplate folded in, since every rule needs a repo-relative path |
| +// for its failure messages. |
| +func relPath(t *testing.T, root, path string) string { |
| + t.Helper() |
| + rel, err := filepath.Rel(root, path) |
| + if err != nil { |
| + t.Fatalf("guards: rel %s: %v", path, err) |
| + } |
| + return filepath.ToSlash(rel) |
| +} |
| + |
| +// fileImports returns the import paths a Go source file declares. It |
| +// parses in ImportsOnly mode — the cheapest mode that still sees the |
| +// import block — so walking every file in the tree on every test run |
| +// stays fast. |
| +func fileImports(t *testing.T, path string) []string { |
| + t.Helper() |
| + fset := token.NewFileSet() |
| + f, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly) |
| + if err != nil { |
| + t.Fatalf("guards: parse %s: %v", path, err) |
| + } |
| + imports := make([]string, 0, len(f.Imports)) |
| + for _, imp := range f.Imports { |
| + p, err := strconv.Unquote(imp.Path.Value) |
| + if err != nil { |
| + t.Fatalf("guards: unquote import in %s: %v", path, err) |
| + } |
| + imports = append(imports, p) |
| + } |
| + return imports |
| +} |
diff --git a/internal/guards/sealtest_containment_test.go b/internal/guards/sealtest_containment_test.go| new file mode 100644 |
| index 0000000..a7e64bb |
| --- /dev/null |
| +++ b/internal/guards/sealtest_containment_test.go |
| @@ -0,0 +1,29 @@ |
| +package guards |
| + |
| +import ( |
| + "strings" |
| + "testing" |
| +) |
| + |
| +// sealtestImport is the plaintext-gate test helper package. Its own doc |
| +// comment already says this — "this package must never be imported from |
| +// non-test code: doing so would drag the testing package into the |
| +// production binary" — but until now that rested on the comment alone. |
| +const sealtestImport = "amadan.net/oficina/sheets/internal/sheets/sealtest" |
| + |
| +// TestSealtestOnlyImportedByTests fails on any non-_test.go file that |
| +// imports sealtest. |
| +func TestSealtestOnlyImportedByTests(t *testing.T) { |
| + root := repoRoot(t) |
| + for _, path := range goFiles(t, root) { |
| + if strings.HasSuffix(path, "_test.go") { |
| + continue |
| + } |
| + rel := relPath(t, root, path) |
| + for _, imp := range fileImports(t, path) { |
| + if imp == sealtestImport { |
| + t.Errorf("%s: imports %q from non-test code (sealtest containment: sealtest imports testing, so it must stay test-only)", rel, sealtestImport) |
| + } |
| + } |
| + } |
| +} |