oficina / sheets Public

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
Paul Campbell pushed by paul@keymail.dev f4ba06341c4e1ad577387c9d1e3b3ecd6e076e6c parent b4d3e25
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 −0
  • Makefile +60 −0
  • internal/guards/calc_isolation_test.go +39 −0
  • internal/guards/excelize_confinement_test.go +31 −0
  • internal/guards/forbidden_imports_test.go +29 −0
  • internal/guards/linecap_test.go +56 −0
  • internal/guards/repo_test.go +127 −0
  • internal/guards/sealtest_containment_test.go +29 −0
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)
+ }
+ }
+ }
+}