Development Guide¶
This is about working on the module, not with it
Everything here concerns contributing to config itself. If you are configuring an
application using it, start at the tutorial.
Setup¶
Prerequisites¶
- Go 1.26.5 or later — the module's
go.modrequires it. - just — the task runner;
just --listshows everything. - golangci-lint — the linter is part of the definition of done, not an optional pass.
- mockery — only needed if you change an interface.
- zensical — only needed to build or serve the documentation site.
git clone https://gitlab.com/phpboyscout/go/config.git
cd config
go mod download
just # tidy, lint, test — the default recipe
The recipes that matter¶
| Command | What it does |
|---|---|
just |
tidy, lint, test — run this before you push anything |
just ci |
the full local CI: tidy, test, race, lint |
just test |
unit tests with coverage |
just test-race |
the race detector |
just lint / just lint-fix |
golangci-lint, optionally auto-fixing |
just coverage |
an HTML coverage report |
just deadcode |
unreachable exported symbols |
just vuln |
vulnerability scan |
Project structure¶
The module is deliberately flat. There is no internal/, no pkg/, and no subpackage
except the generated mocks — a library this size does not need a directory tree, and a
flat layout makes the boundaries between components visible in the file names.
config/
├── store.go # the Store: the single owner of all config I/O
├── snapshot.go # immutable resolved configuration + provenance
├── view.go # the scoped read surface over a snapshot
├── layer.go # Layer, Source, and merge/normalisation rules
├── backend.go # Backend / WatchableBackend, file and reader backends
├── env.go, flag.go # the environment and flag backends
├── write.go # WritableBackend, Pending, the write path
├── plan.go # routing a change to the layer that owns it
├── watch.go # fsnotify with a per-path polling fallback
├── settle.go # coalescing a burst of foreign changes
├── notify.go # the observer contract: ordering, exactly-once
├── observed_section.go # ObserveSection[T] and the typed-section binding
├── unmarshal.go # UnmarshalSection[T]
├── typed.go # Value[T] and the named accessors
├── schema.go, validate.go, validate_generic.go
├── mocks/ # generated by mockery — do not edit by hand
├── features/ # godog feature files
└── docs/ # this site
The one architectural rule¶
The Store is the sole owner of configuration I/O. Nothing else reads, writes or
watches a source. Everything else is a view over the immutable snapshot it publishes.
Almost every design decision in the module falls out of that rule, and most bugs found so far have been somewhere that quietly broke it. If a change requires a second component to touch a file, that is a signal to stop and reconsider rather than a detail to work around.
The reasoning is in The Store; the decisions are recorded in the specs.
Workflow¶
- Write or extend a spec first for anything non-trivial. See Feature Specifications — this is a hard rule, not a preference.
- Get the spec approved before implementing. Walk the open questions one at a time rather than picking the convenient answer to keep moving.
- Implement test-first, following the phases the spec defines.
- Run
just ci— tidy, test, race, lint. All four. - Cite the spec and decision number in the commit and the merge request.
- Flip the spec's status to
implementedwhen it merges.
Branching and history¶
- Rebase and fast-forward. No merge commits; a branch that has diverged is rebased onto the latest target before merging.
- Never squash from the merge-request UI. If history genuinely needs squashing, do it
locally and force-push with
--force-with-lease. - Meaningful, self-contained commits survive. Squashing is for consolidating WIP-style commits that add no historical value.
Commit messages¶
Conventional commits, because releaser-pleaser
derives the changelog and the next version from them. The module is pre-1.0, so a breaking
change is a feat! with a BREAKING CHANGE: footer only when it genuinely is one —
consider whether the change can stay additive first.
Explain why, not what. The diff already says what changed.
Conventions¶
Comments explain reasoning, not mechanics¶
The house style is that a comment earns its place by recording something the code cannot: why this approach rather than the obvious one, what broke when it was done differently, what invariant a reader would otherwise violate. A comment restating the line beneath it is noise that rots.
This matters more than usual here because much of the module's behaviour is deliberately counter-intuitive — refusing writes from observers, dropping superseded snapshots, returning a usable store alongside an error. Each of those looks like a bug until you know why.
Errors are named sentinels¶
Every failure a caller might branch on is a package-level Err… value matched with
errors.Is. No string comparison, ever. If you add a failure mode a caller could
reasonably handle differently, it needs a sentinel and a row in the relevant guide's error
table.
ErrInternal means this module has a bug — it must never be reachable through ordinary
misuse of the public API. If a caller can trigger it, it is the wrong error.
Interfaces stay small, and split by capability¶
Backend, WritableBackend and WatchableBackend are separate on purpose: being readable
does not make a source writable or watchable, and a single fat interface would force every
implementation to stub what it cannot do. Prefer adding a narrow interface over widening an
existing one.
Public API changes¶
The module is consumed by other phpboyscout modules and by downstream tools. Before
changing an exported name or signature, check what it costs those consumers, and say so in
the spec. just deadcode finds exported symbols nothing reaches.
Documentation¶
The site follows Diátaxis: tutorial, how-to, explanation, reference. Put a change where it belongs rather than where it is convenient — a guarantee readers need to cite belongs in explanation, and a recipe belongs in a how-to.
Two rules that exist because breaking them has cost real time:
- Every code sample must be run, not merely written. Several guides have shipped
snippets that could not compile — a helper never defined, a
//go:embedat function scope, an output block showing three lines for two changes. Where a sample is important enough to be load-bearing, back it with a test:custombackend_test.goandcoherence_test.goboth exist to keep a documented claim honest. - Never document behaviour without checking it against the spec. Behaviour that contradicts an approved decision is a divergence to fix, not a caveat to write down. This has happened twice; both times the documentation was written first and the divergence found afterwards.
Do not run zensical build while zensical serve is running
Both use site/. Building — or cleaning up after a build — empties the directory the
dev server is serving from. The server keeps listening and returns 404 for every page,
which looks exactly like a broken site. Restart the server.
Further reading¶
- Feature Specifications — how decisions get made and recorded
- Testing — the testing philosophy, and the failure modes to watch for
- The Store — why one component owns config I/O
- Backends & capabilities — why the interfaces are split