Skip to content
Merged
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
50 changes: 38 additions & 12 deletions .github/workflows/gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,13 @@ on:
push:
branches: [main]
pull_request:
# Every check below runs a Makefile target, so `make gate` on a developer machine
# is these same commands in the same order — .NOTPARALLEL in the Makefile keeps
# that true under `make -j`. Lint is the one check that does not run a target:
# the action installs the pinned release and runs it, and the step above reads
# that 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,26 +19,45 @@ 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:
# Pinned so a golangci-lint release cannot redden main without a commit,
# and so the two steps below describe the run that produced the findings
# above: they ask golangci-lint which linters it is running, which only
# answers for this step if it is the same build.
version: v2.12.2
# and so the nolint steps below describe the run that produced the
# findings above: they ask golangci-lint which linters it is running,
# which only answers for this step if it is the same build. The pin
# itself lives in the Makefile and is read back by the step above.
version: ${{ steps.lint-version.outputs.version }}
# The action leaves the golangci-lint it installed on PATH for later steps
# (core.addPath in its entrypoint), so these reach that same binary.
- name: nolint grammar
run: ./scripts/verify-nolint-grammar.sh
run: make nolint-grammar
- name: nolint directives
run: ./scripts/check-nolint-linters.sh
run: make nolint
- name: build
run: go build ./...
run: make build
- name: coverage counting
run: ./scripts/verify-coverage-count.sh
- name: coverage
run: ./scripts/check-coverage.sh
run: make coverage-count
- 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
18 changes: 8 additions & 10 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -237,20 +237,18 @@ 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
./scripts/verify-nolint-grammar.sh # how the check below reads a directive
./scripts/check-nolint-linters.sh # every //nolint names a linter that is enabled
go build ./...
./scripts/verify-coverage-count.sh # how the gate below counts a profile
./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 bar `lint`, which the golangci-lint action runs at the version the `Makefile`
pins, so the local command and the job are the same commands in the same order. 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
93 changes: 93 additions & 0 deletions Makefile
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
# 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

# The gate runs one check at a time even under `make -j`. Prerequisites of a
# single target are otherwise eligible to run concurrently, which would both lose
# the CI order this file exists to mirror and let `fuzz` write a reproducer into
# testdata/ while `coverage` is running `go test ./...` over the same tree.
.NOTPARALLEL:

.PHONY: gate fmt vet lint nolint-grammar nolint build coverage-count coverage \
fuzz bench bench-smoke print-lint-version

gate: fmt vet lint nolint-grammar nolint build coverage-count 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

# Both nolint checks run after lint, and in CI reach the golangci-lint the lint
# step left on PATH: they ask it which linters it is running, which only
# describes that step if it is the same build.
nolint-grammar:
./scripts/verify-nolint-grammar.sh

nolint:
./scripts/check-nolint-linters.sh

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

coverage-count:
./scripts/verify-coverage-count.sh

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)
19 changes: 8 additions & 11 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -221,21 +221,18 @@ The design documents are normative — read them before proposing changes to the

## Building

Standard Go tooling. These are the same checks the CI `gate` runs, and all must pass before a
change lands:
One command, and it must pass before a change lands:

```bash
gofmt -l . # must print nothing
go vet ./...
golangci-lint run
./scripts/verify-nolint-grammar.sh # checks how the check below reads a directive
./scripts/check-nolint-linters.sh # every //nolint names a linter that is enabled
go build ./...
go test ./...
./scripts/verify-coverage-count.sh # checks how the gate below counts a profile
./scripts/check-coverage.sh # enforces 100% statement coverage, overall and per package
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 bar `lint`, which the golangci-lint action runs at the version the `Makefile`
pins, so the local command and the job are the same commands in the same order. Read the `Makefile`
for the step list rather than a copy here; `make coverage`, `make fuzz`, `make bench` and the rest
are individually runnable while iterating.

Run a single test with `go test ./ir -run TestName`. Golden IR snapshots are regenerated with
the corpus test's `-update` flag after an intentional change.

Expand Down
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
Loading
Loading