diff --git a/AGENTS.md b/AGENTS.md index f045834..e690fc0 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -67,7 +67,7 @@ make test # Run all tests make lint # Run golangci-lint (.golangci.yml; fails if not installed) make check # fmt + lint + test (mutating: fmt rewrites files) make verify # Non-mutating, credential-free gate for read-only reviewers: tidy diff, vet, gofmt -l, exact-pin lint, non-E2E tests -make ci # Credential-free gate matching CI's fail-fast order (verify-static, then coverage, E2E, race, cross-build) +make ci # Credential-free gate matching CI's fail-fast order (verify-static, docs integrity, then coverage, E2E, race, cross-build) make test-cover # Tests with HTML coverage report make coverage-check # Enforce the absolute floor and committed coverage ratchet make test-coverage-check # Self-test scripts/check-coverage.sh with fixture profiles @@ -81,7 +81,9 @@ Run a single test: `go test -v -run TestName ./updex/` Build workflow: make code changes → `make fmt` → `make build` → smoke-test with `./build/updex --help`. Use `make check` for the quick development loop. Run `make ci` before opening a pull request. It checks module tidiness, vet, -formatting, lint, non-E2E unit and race tests, the separate black-box E2E +formatting, lint, docs integrity (the same `make test-docs-check` and +`node scripts/check-docs.mjs` the `docs-gate` CI job runs, so it needs Node.js +too), non-E2E unit and race tests, the separate black-box E2E suite, and Linux amd64/arm64 builds, and requires `golangci-lint`. The lint step first runs `make lint-version-check`, which fails unless the installed `golangci-lint` matches the pin in `mise.toml` (currently 2.13.1) and was @@ -648,8 +650,8 @@ their canonical targets, never the aliases. `docs/review-rubric.md`, 2. Keep changes focused; unrelated fixes belong in a separate PR. 3. Use Conventional Commits for commit messages **and the pull request title** (see [Commits & Pull Requests](#commits--pull-requests)). -4. Run `make fmt`, `make ci`, and `node scripts/check-docs.mjs` and make - sure they pass. +4. Run `make fmt` and `make ci` (which now includes the docs-integrity + gate) and make sure they pass. 5. Update the documentation and add tests for your change. 6. Classify the change using the [risk tier guide](docs/risk-tiers.md) and include the tier rationale in the pull request diff --git a/Makefile b/Makefile index 1284677..1ac6d54 100644 --- a/Makefile +++ b/Makefile @@ -113,8 +113,12 @@ verify: verify-static @echo "==> unit tests" $(GO) test $$($(GO) list ./... | grep -v '/tests/e2e$$') -## ci: Run the credential-free CI gate (verify-static, then coverage, race, and cross-build) +## ci: Run the credential-free CI gate (verify-static, docs integrity, then coverage, race, and cross-build) ci: verify-static + @echo "==> docs-integrity checker self-test" + $(MAKE) test-docs-check + @echo "==> docs integrity" + node scripts/check-docs.mjs @echo "==> unit tests with coverage" $(GO) test -v $$($(GO) list ./... | grep -v '/tests/e2e$$') -coverprofile=coverage.out -covermode=atomic @echo "==> coverage floor" diff --git a/updex/makefile_ci_docs_gate_contract_test.go b/updex/makefile_ci_docs_gate_contract_test.go new file mode 100644 index 0000000..52cf700 --- /dev/null +++ b/updex/makefile_ci_docs_gate_contract_test.go @@ -0,0 +1,71 @@ +package updex + +import ( + "os" + "regexp" + "strings" + "testing" +) + +// docsGateInvocations are the two commands the `docs-gate` job in +// .github/workflows/test.yml runs. `make ci` claims to be the credential-free +// local equivalent of that workflow, so it must run both as well. +var docsGateInvocations = []struct { + name string + pattern *regexp.Regexp +}{ + { + name: "make test-docs-check", + pattern: regexp.MustCompile(`(?m)^\t@?(?:\$\(MAKE\)|make)(?:\s+--no-print-directory)?\s+test-docs-check\s*$`), + }, + { + name: "node scripts/check-docs.mjs", + pattern: regexp.MustCompile(`(?m)^\t@?node\s+scripts/check-docs\.mjs\s*$`), + }, +} + +// TestMakefileCIRecipeRunsDocsIntegrityGate pins the docs-integrity checks +// into `make ci`. GitHub CI runs `make test-docs-check` and +// `node scripts/check-docs.mjs` in its `docs-gate` job; before this guard the +// local `ci` recipe ran neither, so a broken docs index, a dead relative link, +// or a broken conformance alias passed the local gate and only failed after a +// pull request was already open. The test fails if either invocation is +// dropped from the recipe, is moved after the final "CI gate passed" line, or +// has its failure swallowed. +func TestMakefileCIRecipeRunsDocsIntegrityGate(t *testing.T) { + data, err := os.ReadFile("../Makefile") + if err != nil { + t.Fatalf("read Makefile: %v", err) + } + + recipe := extractRecipe(t, string(data), "ci:") + + passIdx := strings.Index(recipe, "CI gate passed") + if passIdx < 0 { + t.Fatalf("Makefile ci recipe no longer prints \"CI gate passed\"; got:\n%s", recipe) + } + + for _, invocation := range docsGateInvocations { + loc := invocation.pattern.FindStringIndex(recipe) + if loc == nil { + t.Errorf("Makefile ci recipe must invoke `%s` (the docs-gate CI job runs it); got:\n%s", invocation.name, recipe) + continue + } + if loc[0] > passIdx { + t.Errorf("Makefile ci recipe invokes `%s` after printing \"CI gate passed\"; move it before the success line", invocation.name) + } + } + + for _, line := range strings.Split(recipe, "\n") { + trimmed := strings.TrimPrefix(line, "\t") + if !strings.Contains(trimmed, "test-docs-check") && !strings.Contains(trimmed, "check-docs.mjs") { + continue + } + if strings.HasPrefix(trimmed, "-") || strings.HasPrefix(trimmed, "@-") { + t.Errorf("Makefile ci recipe ignores the exit status of a docs-integrity invocation: %q", line) + } + if strings.Contains(trimmed, "||") { + t.Errorf("Makefile ci recipe swallows a docs-integrity failure with a `||` fallback: %q", line) + } + } +}