# Contributing to Einherjar
Thank you for your interest in contributing to Einherjar. This document explains everything you need to know before sending your first Pull Request. Einherjar is developed and maintained by **NOCHEBUENADEV**, the trade name of its founder operating as a *Persona Física con Actividad Empresarial* (PFAE) under Mexican law. Contributions are welcome and valued — but they are accepted under the terms described here, so please read this document fully before you begin. --- ## Table of Contents 1. [Before You Start](#1-before-you-start) 2. [Legal: CLA and Copyright](#2-legal-cla-and-copyright) 3. [Development Setup](#3-development-setup) 4. [Code Standards](#4-code-standards) 5. [Commit Messages](#5-commit-messages) 6. [Submitting a Pull Request](#6-submitting-a-pull-request) 7. [Reporting Bugs](#7-reporting-bugs) 8. [Requesting Features](#8-requesting-features) 9. [What Gets Accepted](#9-what-gets-accepted) --- ## 1. Before You Start **Open an issue first for anything non-trivial.** Before you write a line of code, open an issue describing what you want to change and why. This protects your time: a change that seems straightforward may conflict with a planned refactor, an architectural decision, or the project's direction. Getting alignment before coding means your PR will not be rejected for reasons unrelated to its quality. Exceptions where you can skip the issue: - Typo or documentation-only fix - Test coverage improvement for existing behavior - Trivially obvious bug with a clear, contained fix **Do not submit a PR that changes the public API of any module without prior discussion.** Every Einherjar module has a stability contract. Breaking changes require a major version bump and coordinated updates across dependent modules. --- ## 2. Legal: CLA and Copyright ### Contributor License Agreement Before your first PR can be merged, you must sign the Contributor License Agreement by **posting a specific comment** on your Pull Request. The required comment text and full instructions are in [CLA.md](CLA.md). > Checkboxes in PR descriptions are not used for CLA consent — they can be silently toggled by anyone. A comment is a timestamped, author-attributed record. ### Copyright All original code in Einherjar is copyright **NOCHEBUENADEV**. NOCHEBUENADEV is the registered trade name of its founder, a natural person operating under the Mexican *Persona Física con Actividad Empresarial* (PFAE) regime. When you contribute, you retain ownership of what you wrote. By signing the CLA you grant NOCHEBUENADEV a perpetual, irrevocable, worldwide license to use, modify, sublicense, and redistribute your Contribution — including the right to relicense it. See [CLA.md](CLA.md) for the full terms. ### License Einherjar is licensed under the **GNU Affero General Public License v3.0** (AGPL-3.0). Your Contributions will be distributed under the same license unless the Maintainers exercise their relicensing rights under the CLA. --- ## 3. Development Setup Einherjar uses a Go workspace (`go.work`) that spans all modules. You do not need to `go get` anything — local replacements are wired automatically. ### Prerequisites - Go 1.26+ - Git ### Clone and initialize ```bash git clone https://code.nochebuena.dev/einherjar/<module-name> cd <module-name> # If working across multiple modules, clone the workspace root instead # and all modules will resolve from disk via go.work. ``` ### Verify your setup ```bash go build ./... # must compile clean go vet ./... # no warnings go test ./... # all tests pass gofmt -l . # no output (no unformatted files) ``` All four commands must produce clean output before a PR will be reviewed. --- ## 4. Code Standards These are non-negotiable. Every PR is checked against them. ### One exported type per file (CT-6) Each non-test `.go` file may contain **at most one exported TypeSpec** (type, struct, or interface declaration). Unexported helpers, constants, and functions may coexist in the same file. `_test.go` files are exempt. This rule exists to keep the codebase navigable: a developer who knows the type name can immediately predict the file name. ``` provider.go ← type Provider interface { ... } ✓ one exported type component.go ← type Component interface { ... } ✓ one exported type new.go ← func New(...) + unexported impl ✓ zero exported types ``` ### Formatting All code must be formatted with `gofmt`. No exceptions. If `gofmt -l .` produces output, the PR will not be merged. Do not configure your editor to use `goimports` as a replacement — it may add import groups that diverge from the project style. Use `gofmt` + manual import management. ### Naming conventions - Follow standard Go naming: `CamelCase` for exported, `camelCase` for unexported. - Interfaces that represent a capability are named with an agent noun: `Provider`, `Sender`, `Checkable`. - Interfaces that represent a full component are named `Component`. - Config structs are named `Config`. One config struct per module root. - Constructors are named `New` (main) or `NewXxx` (adapters and variants). ### Error handling - Use `core/xerrors` for all errors returned from public API. Never return raw `errors.New` or `fmt.Errorf` from exported functions. - Error codes must map to the gRPC canonical set defined in `xerrors`. If you need a new code, open an issue first. - Do not swallow errors silently. Log at the appropriate level or return them. ### No comments unless necessary Do not add comments that restate what the code already says. Only add a comment when the **why** is non-obvious: a hidden constraint, a subtle invariant, a workaround for a known upstream bug. If removing the comment would not confuse a future reader, do not write it. ### Dependencies Do not add new external dependencies without opening an issue and getting explicit approval first. Einherjar modules are deliberately lean. Every new dependency increases the blast radius for downstream consumers. --- ## 5. Commit Messages Follow the [Conventional Commits](https://www.conventionalcommits.org/) specification: ``` <type>(<scope>): <short description> [optional body] ``` | Type | When to use | |---|---| | `feat` | New exported function, type, or behavior | | `fix` | Bug fix in existing behavior | | `docs` | Documentation only | | `test` | Tests only, no production code change | | `refactor` | Code restructure with no behavior change | | `chore` | Build system, CI, dependency updates | **Scope** is the module name without the `einherjar/` prefix: `core`, `web`, `db-postgres`, `cache-valkey`, etc. Examples: ``` feat(cache-valkey): add IncrWithTTL for atomic fixed-window counters fix(db-postgres): handle pgconn deadline exceeded as ErrDeadlineExceeded docs(web): document rate limiter fail-open behavior ``` Keep the subject line under 72 characters. Write in the imperative mood ("add", "fix", "remove" — not "added", "fixes", "removed"). --- ## 6. Submitting a Pull Request 1. **Open an issue first** (see §1) unless the change is trivial. 2. Fork the repository and create a branch from `main`: ```bash git checkout -b feat/your-feature-name ``` 3. Make your changes following the standards in §4. 4. Ensure all verification commands pass (§3). 5. Open the PR against `main` using the provided PR template. 6. **Post the CLA comment** on the PR before requesting review (see §2 and [CLA.md](CLA.md)). 7. Respond to review feedback. Keep the review cycle short by addressing all comments before re-requesting review. ### Branch naming | Prefix | Use for | |---|---| | `feat/` | New features | | `fix/` | Bug fixes | | `docs/` | Documentation changes | | `test/` | Test additions or improvements | | `refactor/` | Refactors without behavior change | ### PR size Keep PRs focused. A PR that does one thing is easier to review, faster to merge, and safer to revert if needed. If your change naturally spans multiple concerns, split it into multiple PRs. --- ## 7. Reporting Bugs Open an issue with the following information: - **Module** affected (`einherjar/db-postgres`, `einherjar/web`, etc.) - **Go version** (`go version`) - **Minimal reproduction** — the smallest code snippet that demonstrates the problem - **Expected behavior** vs **actual behavior** - **Error output** if applicable (sanitize any credentials or sensitive data) Do not open a PR to fix a bug without first opening an issue. The bug may be intentional behavior, already fixed on `main`, or caused by something outside the module. --- ## 8. Requesting Features Open an issue with: - **What** you want to add and **why** it belongs in the framework (not in application code) - **Which module** it affects, or whether it requires a new module - **API sketch** — what the interface, function signature, or config field would look like - **Alternative approaches** you considered Feature requests that add a new external dependency, change a public interface, or cross module boundaries require longer discussion before approval. --- ## 9. What Gets Accepted Einherjar is a **focused framework**. It covers a specific, well-defined set of infrastructure concerns. Contributions that fall outside that scope — however well-written — will not be merged. What fits: - Bug fixes in existing behavior - Performance improvements with benchmarks - Missing error mappings for existing drivers - Documentation improvements and example corrections - Test coverage for untested edge cases What does not fit without prior architectural agreement: - New modules (open an issue first) - New external dependencies - Changes to public interfaces in any module - Features that belong in application code rather than the framework If you are unsure, open an issue and ask. It costs nothing and saves everyone time. --- *Einherjar was built for those who come after. Contributions that hold to that standard — clear, documented, tested, designed for the developer who was never in the room — are always welcome.* — **NOCHEBUENADEV**
This commit is contained in:
295
compliance_test.go
Normal file
295
compliance_test.go
Normal file
@@ -0,0 +1,295 @@
|
||||
package worker
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"go/ast"
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"code.nochebuena.dev/einherjar/contracts/lifecycle"
|
||||
"code.nochebuena.dev/einherjar/contracts/logging"
|
||||
)
|
||||
|
||||
// Compile-time interface checks (CT-5 / I-8).
|
||||
var _ lifecycle.Component = (Component)(nil)
|
||||
var _ Provider = (Component)(nil)
|
||||
|
||||
// --- CT-6: at most one exported TypeSpec per non-test, non-doc file ---
|
||||
|
||||
func TestAtMostOneExportedTypePerFile(t *testing.T) {
|
||||
fset := token.NewFileSet()
|
||||
pkgs, err := parser.ParseDir(fset, ".", func(fi os.FileInfo) bool {
|
||||
name := fi.Name()
|
||||
return !strings.HasSuffix(name, "_test.go") && name != "doc.go"
|
||||
}, 0)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
for _, pkg := range pkgs {
|
||||
for path, file := range pkg.Files {
|
||||
base := filepath.Base(path)
|
||||
count := 0
|
||||
for _, decl := range file.Decls {
|
||||
gd, ok := decl.(*ast.GenDecl)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
for _, spec := range gd.Specs {
|
||||
ts, ok := spec.(*ast.TypeSpec)
|
||||
if ok && ts.Name.IsExported() {
|
||||
count++
|
||||
}
|
||||
}
|
||||
}
|
||||
if count > 1 {
|
||||
t.Errorf("%s: %d exported TypeSpecs (max 1)", base, count)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Config defaults (S-4) ---
|
||||
|
||||
func TestDefaultConfig_OptionalFields(t *testing.T) {
|
||||
cfg := DefaultConfig()
|
||||
if cfg.PoolSize == 0 {
|
||||
t.Error("PoolSize must have a default")
|
||||
}
|
||||
if cfg.BufferSize == 0 {
|
||||
t.Error("BufferSize must have a default")
|
||||
}
|
||||
if cfg.ShutdownTimeout == 0 {
|
||||
t.Error("ShutdownTimeout must have a default")
|
||||
}
|
||||
// TaskTimeout == 0 is intentional: means no per-task deadline
|
||||
}
|
||||
|
||||
// --- New / lifecycle ---
|
||||
|
||||
func TestNew_NotNil(t *testing.T) {
|
||||
if New(newLogger(), Config{}) == nil {
|
||||
t.Fatal("New returned nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNew_AppliesDefaults(t *testing.T) {
|
||||
c := New(newLogger(), Config{PoolSize: 0, BufferSize: 0}).(*workerImpl)
|
||||
if c.cfg.PoolSize != 5 {
|
||||
t.Errorf("PoolSize = %d, want 5", c.cfg.PoolSize)
|
||||
}
|
||||
if c.cfg.BufferSize != 100 {
|
||||
t.Errorf("BufferSize = %d, want 100", c.cfg.BufferSize)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Behavioral tests ---
|
||||
|
||||
func TestWorker_DispatchAndExecute(t *testing.T) {
|
||||
done := make(chan struct{})
|
||||
c := startWorker(t, Config{PoolSize: 1, BufferSize: 10, ShutdownTimeout: time.Second})
|
||||
|
||||
c.Dispatch(func(ctx context.Context) error {
|
||||
close(done)
|
||||
return nil
|
||||
})
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("task not executed in time")
|
||||
}
|
||||
_ = c.OnStop()
|
||||
}
|
||||
|
||||
func TestWorker_BackpressureFull(t *testing.T) {
|
||||
block := make(chan struct{})
|
||||
c := startWorker(t, Config{PoolSize: 1, BufferSize: 1, ShutdownTimeout: time.Second})
|
||||
|
||||
c.Dispatch(func(ctx context.Context) error { <-block; return nil }) // fills worker
|
||||
c.Dispatch(func(ctx context.Context) error { return nil }) // fills buffer
|
||||
|
||||
ok := c.Dispatch(func(ctx context.Context) error { return nil }) // should be dropped
|
||||
if ok {
|
||||
t.Error("expected Dispatch to return false when queue is full")
|
||||
}
|
||||
close(block)
|
||||
_ = c.OnStop()
|
||||
}
|
||||
|
||||
func TestWorker_OnStop_DrainsQueue(t *testing.T) {
|
||||
var count int64
|
||||
c := startWorker(t, Config{PoolSize: 2, BufferSize: 50, ShutdownTimeout: 5 * time.Second})
|
||||
|
||||
for i := 0; i < 10; i++ {
|
||||
c.Dispatch(func(ctx context.Context) error {
|
||||
atomic.AddInt64(&count, 1)
|
||||
return nil
|
||||
})
|
||||
}
|
||||
_ = c.OnStop()
|
||||
|
||||
if atomic.LoadInt64(&count) != 10 {
|
||||
t.Errorf("expected 10 tasks completed, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorker_OnStop_Timeout(t *testing.T) {
|
||||
c := startWorker(t, Config{PoolSize: 1, BufferSize: 1,
|
||||
ShutdownTimeout: 50 * time.Millisecond})
|
||||
|
||||
c.Dispatch(func(ctx context.Context) error {
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
return nil
|
||||
})
|
||||
|
||||
start := time.Now()
|
||||
_ = c.OnStop()
|
||||
elapsed := time.Since(start)
|
||||
|
||||
if elapsed > 300*time.Millisecond {
|
||||
t.Errorf("OnStop blocked too long: %v", elapsed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWorker_TaskTimeout(t *testing.T) {
|
||||
var ctxCancelled int64
|
||||
c := startWorker(t, Config{
|
||||
PoolSize: 1, BufferSize: 10,
|
||||
TaskTimeout: 50 * time.Millisecond,
|
||||
ShutdownTimeout: time.Second,
|
||||
})
|
||||
|
||||
done := make(chan struct{})
|
||||
c.Dispatch(func(ctx context.Context) error {
|
||||
defer close(done)
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
atomic.StoreInt64(&ctxCancelled, 1)
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("task did not complete in time")
|
||||
}
|
||||
if atomic.LoadInt64(&ctxCancelled) != 1 {
|
||||
t.Error("expected task context to be cancelled by TaskTimeout")
|
||||
}
|
||||
_ = c.OnStop()
|
||||
}
|
||||
|
||||
func TestWorker_MultipleWorkers(t *testing.T) {
|
||||
const n = 5
|
||||
started := make(chan struct{}, n)
|
||||
block := make(chan struct{})
|
||||
|
||||
c := startWorker(t, Config{PoolSize: n, BufferSize: n, ShutdownTimeout: time.Second})
|
||||
|
||||
for i := 0; i < n; i++ {
|
||||
c.Dispatch(func(ctx context.Context) error {
|
||||
started <- struct{}{}
|
||||
<-block
|
||||
return nil
|
||||
})
|
||||
}
|
||||
|
||||
timer := time.NewTimer(time.Second)
|
||||
defer timer.Stop()
|
||||
for i := 0; i < n; i++ {
|
||||
select {
|
||||
case <-started:
|
||||
case <-timer.C:
|
||||
t.Fatalf("only %d/%d workers started concurrently", i, n)
|
||||
}
|
||||
}
|
||||
close(block)
|
||||
_ = c.OnStop()
|
||||
}
|
||||
|
||||
func TestWorker_TaskError(t *testing.T) {
|
||||
c := startWorker(t, Config{PoolSize: 1, BufferSize: 10, ShutdownTimeout: time.Second})
|
||||
|
||||
done := make(chan struct{})
|
||||
c.Dispatch(func(ctx context.Context) error {
|
||||
defer close(done)
|
||||
return errors.New("task error")
|
||||
})
|
||||
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("task did not run")
|
||||
}
|
||||
_ = c.OnStop()
|
||||
}
|
||||
|
||||
func TestWorker_Len(t *testing.T) {
|
||||
c := New(newLogger(), Config{PoolSize: 1, BufferSize: 10, ShutdownTimeout: time.Second})
|
||||
if err := c.OnInit(); err != nil {
|
||||
t.Fatalf("OnInit: %v", err)
|
||||
}
|
||||
// Workers are not running yet — queued tasks stay in the channel.
|
||||
blocked := make(chan struct{})
|
||||
c.Dispatch(func(ctx context.Context) error { <-blocked; return nil })
|
||||
c.Dispatch(func(ctx context.Context) error { <-blocked; return nil })
|
||||
if got := c.Len(); got != 2 {
|
||||
t.Errorf("Len() = %d, want 2", got)
|
||||
}
|
||||
close(blocked)
|
||||
_ = c.OnStop()
|
||||
}
|
||||
|
||||
func TestWorker_Lifecycle(t *testing.T) {
|
||||
c := New(newLogger(), Config{PoolSize: 2, BufferSize: 10, ShutdownTimeout: time.Second})
|
||||
if err := c.OnInit(); err != nil {
|
||||
t.Fatalf("OnInit: %v", err)
|
||||
}
|
||||
if err := c.OnStart(); err != nil {
|
||||
t.Fatalf("OnStart: %v", err)
|
||||
}
|
||||
done := make(chan struct{})
|
||||
c.Dispatch(func(ctx context.Context) error { close(done); return nil })
|
||||
select {
|
||||
case <-done:
|
||||
case <-time.After(time.Second):
|
||||
t.Fatal("task not executed")
|
||||
}
|
||||
if err := c.OnStop(); err != nil {
|
||||
t.Fatalf("OnStop: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- helpers ---
|
||||
|
||||
type stubLogger struct{}
|
||||
|
||||
func newLogger() *stubLogger { return &stubLogger{} }
|
||||
|
||||
func (s *stubLogger) Debug(msg string, args ...any) {}
|
||||
func (s *stubLogger) Info(msg string, args ...any) {}
|
||||
func (s *stubLogger) Warn(msg string, args ...any) {}
|
||||
func (s *stubLogger) Error(msg string, err error, args ...any) {}
|
||||
func (s *stubLogger) With(args ...any) logging.Logger { return s }
|
||||
func (s *stubLogger) WithContext(ctx context.Context) logging.Logger { return s }
|
||||
|
||||
func startWorker(t *testing.T, cfg Config) Component {
|
||||
t.Helper()
|
||||
c := New(newLogger(), cfg)
|
||||
if err := c.OnInit(); err != nil {
|
||||
t.Fatalf("OnInit: %v", err)
|
||||
}
|
||||
if err := c.OnStart(); err != nil {
|
||||
t.Fatalf("OnStart: %v", err)
|
||||
}
|
||||
return c
|
||||
}
|
||||
Reference in New Issue
Block a user