Skip to content

Establish .config/ as the home for tool configuration, enforced by .repo/ - #11

Merged
justinmerrell merged 7 commits into
mainfrom
chore/config-governance-structure
Aug 9, 2026
Merged

Establish .config/ as the home for tool configuration, enforced by .repo/#11
justinmerrell merged 7 commits into
mainfrom
chore/config-governance-structure

Conversation

@justinmerrell

Copy link
Copy Markdown
Contributor

Why

The repo already declares the right philosophy in CONFIGURATION.md"One need, one place" — and already proves it once (mise.toml redirected via MISE_GLOBAL_CONFIG_FILE, ten cache dirs funnelled into one tree). What was missing is the generalization: there was no declared home for tool configuration, so the first contributor to add a linter would drop a dotfile at the root and junk-drawer accretion would begin.

This is a template. Whatever it ships is replicated into every repo scaffolded from it, so the convention had to be settled before the configs exist — and backed by something stronger than memory.

What changed

development-container/
├── .config/        lefthook.yml  markdownlint.jsonc  yamllint.yaml
│                   actionlint.yaml  codespell.cfg  README.md
├── .devcontainer/  provisioning only — scope unchanged
├── .github/        workflows/  rulesets/
├── .repo/          the `repo` CLI + 4 policies
├── taskfiles/      lint.  repo.
├── .gitattributes  .gitignore
├── Taskfile.yml    CONFIGURATION.md  README.md

Four visible root entries. .config/ is dotted deliberately: it is repo infrastructure and sits alongside .devcontainer/, .github/, .repo/. What is visible at the root is content you edit; what is dotted is machinery that operates on it.

lefthook.yml moved into .config/ and is found there with no flag, env var, or wrapperMainConfigNames in lefthook's internal/config/loader.go is ["lefthook", ".lefthook", ".config/lefthook"]. Taskfile.yml stays at the root because go-task's discovery list is only the eight Taskfile.* variants and --taskfile would break bare task <name>.

What is now enforced

repo check runs four policies — 20 violation classes. Because there is no prose rules layer, enforcement has to explain itself: every violation carries a stable code, the reason the rule exists, and the action that resolves it.

Policy Codes Enforces
config CFG-01..07 Tool config lives in .config/, every file is indexed and has a caller, nothing at the root shadows it
ports PORT-01..05 Port table ↔ forwardPorts/portsAttributes ↔ compose published ports agree, and stay in 15432–15460
hooks HOOK-01..04 Every lefthook job has a CI counterpart and vice versa, or a recorded reason why not
rulesets RS-01..04 Committed branch rulesets stay valid and in step with the CI jobs they require

Two of these guard failure modes worth calling out:

  • CFG-06 — lefthook's config search is first-match-wins, so a stray root lefthook.yml would silently shadow .config/lefthook.yml: no warning, different hooks. Confirmed reproducible before writing the check.
  • RS-03 — a required status check is matched by a CI job's display name, so renaming a job without updating the ruleset leaves every PR waiting forever on a check that can never report, and clearing it needs admin access at exactly the moment the repo has become unmergeable.

Lint gates (markdownlint, yamllint, actionlint, codespell) run pre-commit on staged files and again in CI. Tool versions have exactly one source: .devcontainer/mise.toml, which CI resolves through jdx/mise-action via MISE_GLOBAL_CONFIG_FILE.

Judgment calls worth reviewing

  • codespell, not cspell — matches musher-dev/platform's config/spelling/codespell.cfg and its codespell policy module.
  • Rulesets are a policy, not a separate workflow — platform uses a rulesets-validate workflow. Making it the fourth repo policy means no new workflow, no new CI job, and no new parity-allowlist entry, since the governance job already runs repo check.
  • One CRLF workaround survived deliberately. .gitattributes governs only files Git checks out. .devcontainer/.env is gitignored and hand-edited, so a Windows editor can reintroduce CR at any time and docker run --env-file hard-rejects it. The redundant *.sh postCreateCommand pass is gone; strip_crlf() in initialize.sh stays, with a comment explaining the asymmetry.
  • .repo/ has no src/. Its purpose is to stop Python importing a local tree instead of the installed package, which only happens when the package is in the working directory — and this one is under .repo/, which is never where anyone works. The import name governance does collide with a real (unrelated) PyPI package; the distribution name stays the unique repo-governance, and uv tool install isolates the venv, so this is theoretical. A one-line hatchling sources mapping decouples them if it ever matters.

Divergence from platform, accepted: platform uses visible config/<domain>/ buckets. Justin's call is to migrate platform to .config/ later. Platform's rule file also contains an error — it lists Lefthook among tools that "reject every config-path flag," which is false; the corrected reasoning is in this repo's CONFIGURATION.md and is worth a follow-up PR upstream.

Repo lint debt fixed along the way

The new gates had to pass on the repo's own files: 114 markdownlint findings → 0 (fence languages, table delimiter style, reflow to the 80/120 rulers), one over-long compose line (split inside a folded scalar — the parsed YAML document is byte-identical), and a codespell false positive fixed by renaming a badly-named shell variable rather than weakening the dictionary. codespell also caught a genuine typo in the new Python.

Verification

Run from the WSL host rather than inside the container, so real binaries were fetched instead of assumed: lefthook and markdownlint via npx, actionlint and Task via go install (Task 3.52.0 — the same version the Feature pins), yamllint and codespell from PyPI wheels, and the actual uv binary.

  • task lint:all, repo check (directly, through Task, and from a subdirectory), shellcheck, lefthook validate — all pass
  • 15 negative tests, one per triggerable violation class — each confirmed to fire and then clear
  • Each lint gate confirmed to fail on a real defect, not just pass when clean
  • lefthook validate --verbose prints loading config: .../.config/lefthook.yml; the pre-commit hook fired on every commit in this branch
  • The wheel builds with exactly the 17 expected modules and no strays; uv tool install ./.repo succeeds and the installed repo executable runs clean

Not verified locally — no Docker daemon on this host: the mise pins actually resolving (pipx:/aqua: backends), jdx/mise-action in CI, and a full devcontainer rebuild. Those need this PR's CI run and a container rebuild.

Note for reviewers

The Repo Structure and Lint CI jobs are new, so .github/rulesets/main-branch.json lists them as required status checks. The ruleset is not applied automatically — it must be imported once from repository settings, per .github/rulesets/RULESETS.md.

🤖 Generated with Claude Code

justinmerrell and others added 7 commits August 9, 2026 14:53
The working tree had accumulated pure-CRLF churn (361 insertions /
361 deletions across devcontainer.json, devcontainer-lock.json and
validate.yaml, with `git diff --ignore-cr-at-eol` empty). The repo had
no .gitattributes and core.autocrlf unset, so a Windows-mounted WSL
checkout rewrote files on save.

Add .gitattributes and renormalize, then retire the workaround it makes
redundant: the `fix-crlf` postCreateCommand step that sed-stripped CR
from .devcontainer/scripts/*.sh on every container create.

Keep strip_crlf() in initialize.sh. It is not redundant: .gitattributes
only governs files Git checks out, and .devcontainer/.env is gitignored,
generated locally and hand-edited, so a Windows editor can reintroduce CR
at any time -- which `docker run --env-file` hard-rejects.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Declare one canonical location for linter, formatter and hook config
before the configs exist, so the template does not replicate a
root-dotfile junk drawer into every repo scaffolded from it.

.config/ is dotted deliberately: it is repo infrastructure and sits
alongside .devcontainer/, .github/ and .repo/. Visible entries at the
root are content you edit; dotted ones are machinery that operates on it.

- Move lefthook.yml to .config/lefthook.yml. Lefthook discovers this
  natively (MainConfigNames in internal/config/loader.go is
  ["lefthook", ".lefthook", ".config/lefthook"]) -- no flag, env var or
  wrapper involved. Verified with `lefthook validate --verbose`.
- Modernize it to the v2 jobs API; mise already pins lefthook 2.1.10 but
  the file still used the v1 `commands` key.
- Add markdownlint, yamllint, actionlint and codespell configs, each
  reached by an explicit config-path flag from its caller.
- Add .config/README.md indexing every file, its tool, and how it is
  reached; document the first-match-wins search order that lets a stray
  root lefthook.yml silently shadow .config/lefthook.yml.
- Add a "Where Configuration Lives" section to CONFIGURATION.md with the
  four-way decision rule, and record why there is no .editorconfig.
- Fix the repo's own lint debt so the new gates pass from day one:
  114 markdownlint findings to 0 (fence languages, table delimiter
  style, reflow to the 120-col rulers), one over-long compose line
  (split inside a folded scalar; the parsed document is unchanged), and
  a codespell false positive fixed by renaming a shell variable rather
  than by weakening the dictionary.
- Correct stale config/observability/ paths left over from an earlier
  refactor.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ship working defaults rather than an empty convention: markdownlint,
yamllint, actionlint and codespell now run locally on staged files and
again in CI, using the configs added in the previous commit.

Version pinning has exactly one source. .devcontainer/mise.toml pins all
four tools with fully-qualified backends (npm:, pipx:, aqua:), and CI
resolves the same file through jdx/mise-action via MISE_GLOBAL_CONFIG_FILE,
so local and CI cannot drift. Also pin arduino/setup-task to 3.52.0 to
match the go-task Feature instead of floating on 3.x.

Split Task into taskfiles/ modules included from the root Taskfile.yml.
Verified that included taskfiles execute with the repo root as working
directory, so the relative config paths resolve.

Every lint invocation names its config explicitly (--config, -c,
-config-file); only lefthook relies on discovery, and only because it
searches .config/ natively.

Verified end to end: all four gates pass clean, each fails on a real
defect, and the lefthook jobs glob-scope correctly (yaml/actions skip
when no matching file is staged).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This template's layout is replicated into every repo scaffolded from it,
so a convention that holds only while someone remembers it will not hold.
Turn the rules into checks that fail a build instead of a review.

`.repo/` mirrors `.github/` and `.devcontainer/`: infrastructure that
operates on the repository rather than being part of its content. It is a
uv project exposing a `repo` CLI, installed by the devcontainer bootstrap
and by CI, and invoked identically from both plus pre-commit.

Three policies, each splitting declaration from detection so the reasoning
has a home (violations.py) separate from the mechanics (check.py):

  config  CFG-01..07  tool config lives in .config/, every file is indexed
                      and has a caller, nothing at the root shadows it
  ports   PORT-01..05 the port table, forwardPorts/portsAttributes and
                      compose published ports agree and stay in range
  hooks   HOOK-01..04 lefthook and CI stay in step

Because there is no prose rules layer, enforcement has to explain itself:
every violation carries a stable code, the reason the rule exists, and the
action that resolves it.

The hooks policy records one-way checks explicitly rather than ignoring
them -- LOCAL_ONLY and CI_ONLY name each check that runs in only one place
with its reason (build takes minutes, compose needs a Docker daemon,
block-devcontainer-env has nothing to assert in CI). HOOK-04 fails when an
entry outlives what it excused, so the allowlist cannot quietly widen.

Verified: 11 negative tests, one per violation class, each confirmed to
fire and then clear. The package builds and installs with the real
`uv tool install ./.repo`, and the installed `repo` executable runs clean
directly, through Task, and from a subdirectory.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Branch protection lives only in the GitHub UI by default, where it is
neither reviewable nor restorable. Commit it as JSON, and guard the one
failure mode that makes it dangerous.

A required status check is matched by a CI job's display name, so renaming
a job in validate.yaml without updating the ruleset leaves every pull
request waiting forever on a check that can never report -- and clearing it
needs admin access at exactly the moment the repo has become unmergeable.
RS-03 catches that before the commit lands. RS-04 is the mirror: a job that
runs without being required is advisory, so a red run can still merge.

Implemented as a fourth `repo` policy rather than a separate
rulesets-validate workflow (which is how platform does it). The governance
job and pre-commit hook already run `repo check`, so this needs no new
workflow, no new CI job, and no new parity-allowlist entry -- and being a
policy means it reports its own reasoning like the others.

Shape only: it never diffs live GitHub state, because reading rulesets
needs administration:read and the default GITHUB_TOKEN does not have it.
RULESETS.md documents the manual drift check instead of adding a
long-lived PAT.

Verified with four negative tests, including the job-rename deadlock.

Also refresh README: the task table, the lint-config pointer, and a CI
section that described three jobs when there are now seven.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Drop the src/ layer, rename the package repo_governance -> governance,
and split the two meanings of "violations".

src/ exists to stop Python importing a local source tree in place of the
installed package, which only happens when the package sits in the working
directory. This one sits under .repo/, which is never where anyone works --
verified that neither name is importable from the repo root. .repo/ already
provides the separation src/ would, so the level bought nothing and cost a
segment on every path:

  .repo/src/repo_governance/policies/config/check.py
  .repo/governance/policies/config/check.py

The package name also no longer repeats what the directory already says.
The distribution stays repo-governance, which is the unique name and the
one that would matter if this were ever published; the import name is
generic but the tool is installed into an isolated uv venv holding only
itself and pyyaml.

Two smaller cleanups:

- governance/violations.py -> reporting.py. It holds the Violation and
  Report primitives, while every policy also has its own violations.py
  holding declarations. One filename for two different jobs was a
  needless re-read every time.
- The POLICIES registry moves from cli.py into policies/__init__.py, so
  adding a policy touches only the policies package and cli.py never
  names an individual policy.

Verified: wheel contains exactly the 17 expected modules and no strays,
`uv tool install ./.repo` succeeds, and all 15 negative tests still fire
and clear against the reinstalled binary.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
astral-sh/setup-uv stopped publishing major-only tags after v7.6, so
`@v9` does not resolve and the Repo Structure job failed at "Set up job"
before running a step. v8.x and v9.0.0 have no floating aliases.

actionlint cannot catch this -- it does not resolve action refs over the
network.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@justinmerrell
justinmerrell merged commit 623e17e into main Aug 9, 2026
7 checks passed
@justinmerrell
justinmerrell deleted the chore/config-governance-structure branch August 9, 2026 10:24
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant