Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 30 additions & 5 deletions .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,11 @@ on:
push:
branches: [main]
pull_request:
# Every check below runs a Makefile target, so `make gate` on a developer machine
# is these same commands. Lint is the one step that does not: the action installs
# the pinned release and runs it, reading the pin back from the Makefile so the
# version still has a single definition. Add a check to the Makefile and give it
# a step here; do not spell a command out in this file.
jobs:
gate:
runs-on: ubuntu-latest
Expand All @@ -12,12 +17,32 @@ jobs:
with:
go-version: "1.26.3"
- name: gofmt
run: test -z "$(gofmt -l $(git ls-files '*.go'))"
run: make fmt
- name: vet
run: go vet ./...
run: make vet
- name: lint version
id: lint-version
run: echo "version=$(make -s print-lint-version)" >>"$GITHUB_OUTPUT"
- name: lint
uses: golangci/golangci-lint-action@v8.0.0
with:
version: ${{ steps.lint-version.outputs.version }}
- name: build
run: go build ./...
- name: coverage
run: ./scripts/check-coverage.sh
run: make build
- name: test (race detector, exactly 100% coverage)
run: make coverage
- name: fuzz
id: fuzz
run: make fuzz
- name: benchmarks
run: make bench-smoke
# A crash the fuzz step finds is written to <pkg>/testdata/fuzz/<Target>/
# in the runner's workspace and would otherwise die with it, leaving a red
# gate and no reproducer to commit.
- name: upload fuzz findings
if: failure() && steps.fuzz.outcome == 'failure'
uses: actions/upload-artifact@v4
with:
name: fuzz-findings
path: "**/testdata/fuzz/**"
if-no-files-found: ignore
14 changes: 7 additions & 7 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,17 +206,17 @@ below are the ones most likely to bite in this codebase — the full guide gover
- **Logging:** `log/slog` only, injected — but note the stronger repo invariant: pipeline
stages don't log at all; they return diagnostics.

Before claiming any Go work done, run and pass the same checks CI's `gate` job runs
(`.github/workflows/gate.yml`), in that order:
Before claiming any Go work done, run and pass the gate:

```bash
gofmt -l $(git ls-files '*.go') # must print nothing
go vet ./...
golangci-lint run # must pass clean
go build ./...
./scripts/check-coverage.sh # go test ./... + exactly 100% statement coverage
make gate
```

That is not a summary of CI — it is what CI runs. Every check in `.github/workflows/gate.yml` runs a
`Makefile` target, so the local command and the job are the same commands by construction. Read the
`Makefile` for the step list rather than restating it here; `make coverage`, `make fuzz`, `make
bench` and the rest are individually runnable while iterating.

**Coverage is a gate at exactly 100%, not a target.** `scripts/check-coverage.sh` counts
statements from the profile rather than reading `go test`'s rounded percentage, so one uncovered
statement fails the build — `go test ./...` passing locally is not evidence the gate passes.
Expand Down
74 changes: 74 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# The gate, defined once.
#
# .github/workflows/gate.yml runs these targets, so `make gate` and the CI job
# are the same commands by construction rather than by two lists that agree until
# somebody edits one. Add a check here and the workflow gains a step that calls
# it; there is no second place to keep in step.
#
# make the whole gate, in CI order
# make coverage the suite once, under -race, at exactly 100% coverage
# make bench benchmark timings (the gate only smoke-runs them)
# make fuzz FUZZTIME=5m a longer search than the gate's

GO ?= go

# The golangci-lint release CI installs. The workflow reads it back from
# `make print-lint-version`, so the pin has one definition and no copy: without
# it the action installs whatever it resolves as latest that day, and an
# upstream release reddens main with no change to this repo.
GOLANGCI_LINT_VERSION ?= v2.12.2

# Per-target fuzz budget, bounded on purpose. The gate's job is to keep every
# target executable and to search a little on every change; a campaign is what
# `make fuzz FUZZTIME=5m` is for.
FUZZTIME ?= 10s

.DEFAULT_GOAL := gate

.PHONY: gate fmt vet lint build coverage fuzz bench bench-smoke print-lint-version

gate: fmt vet lint build coverage fuzz bench-smoke

fmt:
@unformatted="$$(gofmt -l $$(git ls-files '*.go'))"; \
if [ -n "$$unformatted" ]; then \
echo "gofmt: not formatted:" >&2; \
echo "$$unformatted" >&2; \
exit 1; \
fi

vet:
$(GO) vet ./...

# CI installs the pinned release through golangci-lint-action; here the binary
# is whatever is on PATH. A mismatch is reported rather than fatal — a developer
# on a newer release should know their finding set is not CI's, but a tool
# version is not a reason to refuse to run the gate at all.
lint:
@have="$$(golangci-lint version --short 2>/dev/null || true)"; \
want="$(GOLANGCI_LINT_VERSION)"; \
if [ "$$have" != "$${want#v}" ]; then \
echo "warning: local golangci-lint is $${have:-absent}, CI pins $$want" >&2; \
echo "warning: go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@$$want" >&2; \
fi
golangci-lint run

build:
$(GO) build ./...

coverage:
./scripts/check-coverage.sh

fuzz:
./scripts/fuzz.sh $(FUZZTIME)

bench:
./scripts/bench.sh

# One iteration of every benchmark: enough to prove they still build, still find
# their fixtures and still complete, too few to mean anything as a timing.
bench-smoke:
./scripts/bench.sh 1x

print-lint-version:
@echo $(GOLANGCI_LINT_VERSION)
94 changes: 94 additions & 0 deletions compilers/openapi/compile_bench_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package openapi_test // external test package — exercises only the public API

import (
"encoding/json"
"os"
"testing"

"github.com/stretchr/testify/require"

"github.com/dexpace/morphic/compilers"
"github.com/dexpace/morphic/compilers/openapi"
"github.com/dexpace/morphic/ir"
)

// BenchmarkCompile_Petstore measures one whole compile of the golden petstore —
// parse, lower, assemble — which is the pipeline stage every other cost is
// judged against.
//
// It is also the denominator BenchmarkAnchorWalk asks for. That benchmark's
// claim is a ratio: the $dynamicAnchor walk is small next to a compile, which is
// why the index stays a memo instead of being derived at entry. A ratio needs
// both numbers, measured over the same corpus in the same way.
func BenchmarkCompile_Petstore(b *testing.B) {
data := petstoreSpec(b)
c := openapi.New()
src := []compilers.Source{{Path: "petstore.yaml", Data: data}}

b.ReportAllocs()
b.ResetTimer()
for range b.N {
doc, _, err := c.Compile(b.Context(), src, compilers.Options{})
if err != nil {
b.Fatalf("compile: %v", err)
}
if doc == nil {
b.Fatal("compile produced no document")
}
}
}

// BenchmarkMarshalDocument_Petstore measures serializing a compiled document.
// The IR's sum types and BigVal carry hand-written MarshalJSON, and every golden
// snapshot, IR diff and cache entry pays this cost, so it is worth watching
// separately from the compile that produced the document.
func BenchmarkMarshalDocument_Petstore(b *testing.B) {
doc := compilePetstore(b)

b.ReportAllocs()
b.ResetTimer()
for range b.N {
if _, err := json.Marshal(doc); err != nil {
b.Fatalf("marshal: %v", err)
}
}
}

// BenchmarkUnmarshalDocument_Petstore measures reading a document back. It is
// the other half of the round-trip invariant, and the half a consumer of a
// cached or piped IR document pays.
func BenchmarkUnmarshalDocument_Petstore(b *testing.B) {
encoded, err := json.Marshal(compilePetstore(b))
require.NoError(b, err)

b.ReportAllocs()
b.ResetTimer()
for range b.N {
var back ir.Document
if err := json.Unmarshal(encoded, &back); err != nil {
b.Fatalf("unmarshal: %v", err)
}
}
}

// petstoreSpec reads the golden petstore, failing the benchmark rather than
// skipping it: a benchmark that quietly measures nothing is worse than one that
// stops.
func petstoreSpec(b *testing.B) []byte {
b.Helper()
data, err := os.ReadFile(goldenPetstore)
require.NoError(b, err)
require.NotEmpty(b, data)
return data
}

// compilePetstore compiles the golden petstore once, for the benchmarks whose
// subject is what happens to a document afterwards.
func compilePetstore(b *testing.B) *ir.Document {
b.Helper()
doc, _, err := openapi.New().Compile(b.Context(),
[]compilers.Source{{Path: "petstore.yaml", Data: petstoreSpec(b)}}, compilers.Options{})
require.NoError(b, err)
require.NotNil(b, doc)
return doc
}
11 changes: 8 additions & 3 deletions compilers/openapi/internal/schema/anchorindex_bench_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,14 +20,19 @@ import (
//
// Compare against a whole compile rather than reading the number alone — the
// claim in the design is a ratio, and a ratio is what has to stay true.
// BenchmarkCompile_Petstore in compilers/openapi is the other half.
func BenchmarkAnchorWalk(b *testing.B) {
data, err := os.ReadFile("../../testdata/conformance/openapi/allof-inline-merge.yaml")
// Four levels up, not two: this package sits at compilers/openapi/internal/
// schema, and the corpus is at the repo root. A missing or unparseable
// fixture stops the benchmark rather than skipping it — a skip is silent
// without -v and exits 0, which is how the shorter path went unnoticed.
data, err := os.ReadFile("../../../../testdata/conformance/openapi/allof-inline-merge.yaml")
if err != nil {
b.Skipf("corpus fixture unavailable: %v", err)
b.Fatalf("corpus fixture unavailable: %v", err)
}
var doc soa.OpenAPI
if _, err := marshaller.Unmarshal(b.Context(), strings.NewReader(string(data)), &doc); err != nil {
b.Skipf("fixture does not parse: %v", err)
b.Fatalf("fixture does not parse: %v", err)
}
root := doc.GetRootNode()

Expand Down
4 changes: 2 additions & 2 deletions docs/micro-compiler-plan.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,8 @@ Every task inherits these. They are not restated per issue.
stop-and-explain, never an `-update`.
- **Coverage stays at exactly 100%.** `./scripts/check-coverage.sh` counts statements from the
profile; one uncovered statement fails the build.
- **The gate, in order:** `gofmt -l`, `go vet ./...`, `golangci-lint run`, `go build ./...`,
`./scripts/check-coverage.sh`.
- **The gate is `make gate`**, which is what `.github/workflows/gate.yml` runs step by step. Read
the `Makefile` for the steps; a list here would only be a copy that goes stale.
- **Every new package needs an `internal/archtest` rules entry**, or
`TestImportGraph_EveryPackageIsRuledOrExempt` fails.
- **Every extracted package ships table-driven unit tests** built without calling `Compile` and
Expand Down
38 changes: 28 additions & 10 deletions internal/harness/path_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package harness_test

import (
"context"
"net"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -75,19 +76,36 @@ func TestCheckPath_EmptyPathIsError(t *testing.T) {
assert.Contains(t, err.Error(), "empty path")
}

// TestCheckPath_UnreadableFileIsError drives CheckPath's single-file branch at a
// path that stats cleanly as a non-directory and still cannot be read.
//
// The unreadable thing is a unix socket rather than a chmod 0o000 regular file,
// and the difference is the point. Permission bits are advisory to root, so the
// permission form had to skip under euid 0 — which left CheckPath's `return nil,
// err` uncovered there, and a checkout that fails the 100% gate for anyone
// building as root, as a container commonly does. Refusing to open a socket for
// reading is not a permission check, so no euid bypasses it.
func TestCheckPath_UnreadableFileIsError(t *testing.T) {
t.Parallel()
if os.Geteuid() == 0 {
t.Skip("root bypasses permission bits, so a chmod 0o000 file stays readable")
}
// A regular file with no read permission stats cleanly (so it is not a
// directory) but fails to read, so CheckPath returns the read error.
dir := t.TempDir()
path := writeSpec(t, dir, "spec.yaml", testspec.Minimal)
require.NoError(t, os.Chmod(path, 0o000))
t.Cleanup(func() { _ = os.Chmod(path, 0o600) })
// A short prefix rather than t.TempDir: a unix socket path is capped near
// 104 bytes, and t.TempDir spells this test's whole name into it.
dir, err := os.MkdirTemp("", "harness")
require.NoError(t, err)
t.Cleanup(func() { _ = os.RemoveAll(dir) })

path := filepath.Join(dir, "spec.yaml")
ln, err := net.Listen("unix", path)
require.NoError(t, err)
t.Cleanup(func() { _ = ln.Close() })

// Establish that the fixture reaches the branch it is written for: a path
// that failed to stat, or that stat called a directory, would leave
// CheckPath before ever calling checkFile.
info, err := os.Stat(path)
require.NoError(t, err)
require.False(t, info.IsDir())

_, err := harness.CheckPath(context.Background(), path)
_, err = harness.CheckPath(context.Background(), path)
require.Error(t, err)
assert.Contains(t, err.Error(), "harness: read")
}
12 changes: 6 additions & 6 deletions ir/bigval_property_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,12 +58,12 @@ var bigValAdversarialSeeds = []string{
// like "05") would have gone unnoticed too, had it not already been fixed by
// the time this property was written.
//
// What the gate runs is the seed corpus, though: `go test` executes a fuzz
// target's seeds and does not search. So the standing coverage is exactly the
// spellings below plus the two tables', and a class absent from all three is
// unprotected until someone runs `-fuzz` — which is why the seeds are chosen
// adversarially rather than drawn from real specs, the same reasoning
// naming_property_test.go records.
// The seeds still carry most of the weight, though: an ordinary `go test`
// executes them and does not search, and the gate's per-target search is
// bounded to seconds (see scripts/fuzz.sh). So the standing coverage is the
// spellings below plus the two tables', plus what a short mutation run reaches
// from them — which is why the seeds are chosen adversarially rather than drawn
// from real specs, the same reasoning naming_property_test.go records.
//
// A rejected input carries no claim: NewBigVal is not required to accept
// everything, only to never accept something json.Valid would refuse. What
Expand Down
15 changes: 9 additions & 6 deletions ir/naming_property_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,12 +52,15 @@ var adversarialRunes = []string{
// output is something the rest of the IR will accept, which is a different
// question and the one nothing was asking.
//
// What the gate runs is the seed corpus: `go test` executes a fuzz target's seeds
// and does not search. So the standing coverage is exactly the spellings listed
// above plus the table's, and a class absent from both is unprotected until
// someone runs `-fuzz`. That is why the seeds are chosen adversarially rather
// than drawn from real specs — a grammar mishandling one script is invisible to a
// corpus that only contains Latin.
// What runs against this target is its seed corpus: `go test` executes a fuzz
// target's seeds and does not search, and the gate's bounded `-fuzz` sweep holds
// this one target back — it reaches GitHub #336 within seconds and would redden
// every unrelated change until that is fixed (scripts/fuzz.sh names it). So the
// standing coverage is exactly the spellings listed above plus the table's, and
// a class absent from both is unprotected until someone runs `-fuzz` by hand.
// That is why the seeds are chosen adversarially rather than drawn from real
// specs — a grammar mishandling one script is invisible to a corpus that only
// contains Latin.
func FuzzCanonicalWords_Properties(f *testing.F) {
for _, seed := range adversarialRunes {
f.Add(seed)
Expand Down
Loading
Loading