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
18 changes: 11 additions & 7 deletions .config/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,10 @@ Policy and rationale: [`CONFIGURATION.md`](../CONFIGURATION.md). Enforcement:
| --- | --- | --- |
| `lefthook.yml` | lefthook | **Auto-discovered.** Lefthook searches `.config/lefthook.*` natively |
| `lefthook-local.yml` | lefthook | Auto-discovered and merged. Gitignored; personal overrides only |
| `markdownlint.jsonc` | markdownlint-cli2 | `--config .config/markdownlint.jsonc` |
| `yamllint.yaml` | yamllint | `-c .config/yamllint.yaml` |
| `actionlint.yaml` | actionlint | `-config-file .config/actionlint.yaml` |
| `codespell.cfg` | codespell | `--config .config/codespell.cfg` |
| `markdown/markdownlint.jsonc` | markdownlint-cli2 | `--config .config/markdown/markdownlint.jsonc` |
| `yaml/yamllint.yaml` | yamllint | `-c .config/yaml/yamllint.yaml` |
| `actions/actionlint.yaml` | actionlint | `-config-file .config/actions/actionlint.yaml` |
| `spelling/codespell.cfg` | codespell | `--config .config/spelling/codespell.cfg` |

Call sites are [`taskfiles/lint.Taskfile.yml`](../taskfiles/lint.Taskfile.yml)
and [`.github/workflows/validate.yaml`](../.github/workflows/validate.yaml).
Expand All @@ -26,9 +26,11 @@ from that same file via `jdx/mise-action`.

## Rules

1. **Flat.** `.config/<tool>.<ext>`. Create a `.config/<tool>/` subdirectory
only when a tool genuinely owns several files (a style directory, a custom
dictionary set). One file per tool needs no folder.
1. **Bucket by concern.** `.config/<concern>/<tool>.<ext>`. A one-file bucket
is fine and collects siblings over time (`markdown/` gains a Vale config,
`security/` a gitleaks one). The single exception is `lefthook.yml`, which
sits at the top level because lefthook's config search does not descend
past `.config/lefthook.*` — bucketing it would silently stop every hook.
2. **No leading dot on filenames.** The directory is already dotted; a second
dot adds nothing.
3. **Pass the path explicitly.** Except for lefthook, which finds this
Expand All @@ -40,6 +42,8 @@ from that same file via `jdx/mise-action`.
above.
5. **Every ignore needs a reason.** Suppressions, allowlists, and disabled
rules carry an inline comment explaining why the exception is acceptable.
6. **Configuration only.** No executables. A build asset belongs beside what
builds it; a repo-level runner belongs in `.repo/governance/`.

## What does *not* live here

Expand Down
File renamed without changes.
12 changes: 6 additions & 6 deletions .config/lefthook.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,17 +61,17 @@ pre-commit:

- name: markdown
glob: "*.md"
run: markdownlint-cli2 --config .config/markdownlint.jsonc {staged_files}
run: markdownlint-cli2 --config .config/markdown/markdownlint.jsonc {staged_files}
fail_text: "Markdown lint failed. Run 'task lint:md:fix', then re-stage."

- name: yaml
glob: "*.{yml,yaml}"
run: yamllint -c .config/yamllint.yaml {staged_files}
fail_text: "YAML lint failed. See .config/yamllint.yaml for the active rules."
run: yamllint -c .config/yaml/yamllint.yaml {staged_files}
fail_text: "YAML lint failed. See .config/yaml/yamllint.yaml for the active rules."

- name: actions
glob: ".github/workflows/*.{yml,yaml}"
run: actionlint -config-file .config/actionlint.yaml {staged_files}
run: actionlint -config-file .config/actions/actionlint.yaml {staged_files}
fail_text: "Workflow lint failed. Run 'task lint:actions' for the full output."

- name: governance
Expand All @@ -83,7 +83,7 @@ pre-commit:

- name: spelling
# No glob: a typo can land in any file type.
run: codespell --config .config/codespell.cfg {staged_files}
run: codespell --config .config/spelling/codespell.cfg {staged_files}
fail_text: >-
Spelling check failed. Run 'task lint:spelling:fix' and review the
diff, or add a justified entry to .config/codespell.cfg.
diff, or add a justified entry to .config/spelling/codespell.cfg.
File renamed without changes.
File renamed without changes.
File renamed without changes.
2 changes: 1 addition & 1 deletion .repo/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ runs `uv tool install ./.repo`). To reinstall after editing it:

| Policy | Codes | Enforces |
| --- | --- | --- |
| `config` | `CFG-01`..`CFG-07` | Tool config lives in `.config/`, every file is indexed and has a caller, nothing at the root shadows it |
| `config` | `CFG-01`..`CFG-08` | Tool config lives in `.config/<concern>/` buckets, every file is indexed and has a caller, nothing at the root shadows it, no executables |
| `ports` | `PORT-01`..`PORT-05` | The port table, `forwardPorts`/`portsAttributes`, and compose published ports all agree and stay in the reserved range |
| `hooks` | `HOOK-01`..`HOOK-04` | Every lefthook job has a CI counterpart and vice versa, or a recorded reason why not |
| `rulesets` | `RS-01`..`RS-04` | Committed branch rulesets stay valid and in step with the CI jobs they require |
Expand Down
55 changes: 46 additions & 9 deletions .repo/governance/policies/config/check.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,10 @@

from __future__ import annotations

import os
import re
from pathlib import Path

from governance import repo
from governance.policies.config import violations as v
from governance.reporting import Report
Expand All @@ -17,6 +21,20 @@
#: Gitignored personal overrides. Present or absent, never indexed.
LOCAL_OVERRIDES = {"lefthook-local.yml", "lefthook-local.yaml"}

#: The only files allowed at the top level of .config/ rather than inside a
#: concern bucket. Lefthook qualifies solely because its config search does
#: not descend past .config/lefthook.* -- bucketing it would silently stop
#: every hook running.
TOP_LEVEL_ALLOWED = {"README.md"} | set(AUTO_DISCOVERED) | LOCAL_OVERRIDES

#: Suffixes that make a file a program rather than a declaration. A denylist
#: rather than a config allowlist because legitimate configs may carry no
#: extension at all, and the failure to prevent is specifically an executable
#: drifting in.
EXECUTABLE_SUFFIXES = {
".sh", ".bash", ".zsh", ".py", ".mjs", ".cjs", ".js", ".ts", ".rb", ".pl",
}

#: Root filenames that would win lefthook's first-match-wins search.
SHADOWING = (
"lefthook.yml", "lefthook.yaml", "lefthook.json", "lefthook.jsonc",
Expand All @@ -38,7 +56,7 @@
".stylelintrc", ".shellcheckrc",
)

#: Files scanned for explicit `.config/<name>` references.
#: Files scanned for explicit `.config/<path>` references.
CALLER_GLOBS = (
"Taskfile.yml",
"taskfiles/*.yml",
Expand All @@ -49,6 +67,19 @@
".devcontainer/scripts/**/*.sh",
)

#: Directory names never worth walking inside .config/.
EXCLUDED_DIR_NAMES = {"node_modules", "__pycache__", ".git"}

_BACKTICKED = re.compile(r"`([^`]+)`")


def _config_files(config_dir: Path) -> list[Path]:
files = []
for dirpath, dirnames, filenames in os.walk(config_dir):
dirnames[:] = [d for d in dirnames if d not in EXCLUDED_DIR_NAMES]
files.extend(Path(dirpath) / f for f in filenames)
return sorted(files)


def _caller_text() -> str:
chunks = []
Expand All @@ -72,24 +103,30 @@ def run() -> Report:
index = index_path.read_text(encoding="utf-8") if index_path.is_file() else None
if index is None:
report.add(v.missing_index())
indexed = set(_BACKTICKED.findall(index)) if index is not None else set()

callers = _caller_text()

for path in sorted(config_dir.iterdir()):
if not path.is_file():
continue
for path in _config_files(config_dir):
rel = path.relative_to(config_dir).as_posix()
name = path.name
if name == "README.md" or name in LOCAL_OVERRIDES:
continue

if name.startswith("."):
report.add(v.dotted_filename(name))
report.add(v.dotted_filename(rel))

if path.suffix in EXECUTABLE_SUFFIXES:
report.add(v.executable_in_config(rel))

if "/" not in rel and name not in TOP_LEVEL_ALLOWED:
report.add(v.misplaced_top_level(name))

if index is not None and f"`{name}`" not in index:
report.add(v.not_in_index(name))
if index is not None and rel not in indexed and name not in indexed:
report.add(v.not_in_index(rel))

if name not in AUTO_DISCOVERED and f"{CONFIG_DIR}/{name}" not in callers:
report.add(v.orphaned(name))
if name not in AUTO_DISCOVERED and f"{CONFIG_DIR}/{rel}" not in callers:
report.add(v.orphaned(rel))

for name in SHADOWING:
if (root / name).is_file():
Expand Down
55 changes: 43 additions & 12 deletions .repo/governance/policies/config/violations.py
Original file line number Diff line number Diff line change
Expand Up @@ -36,47 +36,48 @@ def missing_index() -> Violation:
)


def not_in_index(name: str) -> Violation:
def not_in_index(rel: str) -> Violation:
return Violation(
code="CFG-03",
summary=f"{name} is not listed in the .config/ index",
summary=f"{rel} is not listed in the .config/ index",
reason=(
"A config absent from the index is invisible to the next reader, "
"who cannot tell which tool consumes it or how."
),
fix=f"Add a row for `{name}` to the index table in .config/README.md.",
where=f".config/{name}",
fix=f"Add a row for `{rel}` to the index table in .config/README.md.",
where=f".config/{rel}",
docs=DOCS,
)


def orphaned(name: str) -> Violation:
def orphaned(rel: str) -> Violation:
return Violation(
code="CFG-04",
summary=f"{name} is never referenced by any caller",
summary=f"{rel} is never referenced by any caller",
reason=(
"Configs are passed explicitly, so a file no caller names is dead "
"weight -- it looks authoritative while affecting nothing."
),
fix=(
f"Reference .config/{name} from taskfiles/, Taskfile.yml or a "
f"Reference .config/{rel} from taskfiles/, Taskfile.yml or a "
"workflow, or delete it."
),
where=f".config/{name}",
where=f".config/{rel}",
docs=DOCS,
)


def dotted_filename(name: str) -> Violation:
def dotted_filename(rel: str) -> Violation:
name = rel.rsplit("/", 1)[-1]
return Violation(
code="CFG-05",
summary=f"{name} has a leading dot inside .config/",
summary=f"{rel} has a leading dot inside .config/",
reason=(
"The directory is already dotted. A second dot signals "
"auto-discovery that is not happening and adds nothing."
),
fix=f"Rename to {name.lstrip('.')}.",
where=f".config/{name}",
where=f".config/{rel}",
docs=DOCS,
)

Expand Down Expand Up @@ -105,7 +106,37 @@ def stray_root_config(name: str) -> Violation:
"prevent, and this repo is a template -- whatever it ships is "
"replicated into every repo scaffolded from it."
),
fix=f"Move {name} into .config/ and pass its path explicitly.",
fix=f"Move {name} into .config/<concern>/ and pass its path explicitly.",
where=name,
docs=DOCS,
)


def misplaced_top_level(name: str) -> Violation:
return Violation(
code="CFG-07",
summary=f"{name} sits at the top level of .config/, not in a concern bucket",
reason=(
"The layout is .config/<concern>/<tool>.<ext>. Only the index and "
"lefthook belong at the top level -- lefthook because its config "
"search does not descend past .config/lefthook.*."
),
fix=f"Move {name} into .config/<concern>/ and update its callers.",
where=f".config/{name}",
docs=DOCS,
)


def executable_in_config(rel: str) -> Violation:
return Violation(
code="CFG-08",
summary=f"{rel} is a program, not a declaration",
reason=(
".config/ holds configuration and nothing else. A build asset "
"belongs beside what builds it; a repo-level runner belongs in "
".repo/governance/."
),
fix=f"Move .config/{rel} next to what uses it, or into .repo/.",
where=f".config/{rel}",
docs=DOCS,
)
28 changes: 16 additions & 12 deletions CONFIGURATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ point at another path (Task)?
→ repo root. No alternative — these are orchestration entry points.

Does it configure a linter, formatter, or the git hooks?
→ .config/<tool>.<ext>
→ .config/<concern>/<tool>.<ext> (lefthook.yml stays at .config/ top level)

Does it provision the container itself?
→ .devcontainer/ (see the branches below)
Expand Down Expand Up @@ -58,7 +58,7 @@ the first "yes".
| # | Question | Home | Examples |
| --- | --- | --- | --- |
| 1 | Can the tool *only* load from the repo root, with no flag to point elsewhere? | Repo root | `Taskfile.yml`, `.gitattributes`, `.gitignore` |
| 2 | Does it configure a linter, formatter, or the git hooks? | `.config/` | `lefthook.yml`, `markdownlint.jsonc`, `yamllint.yaml` |
| 2 | Does it configure a linter, formatter, or the git hooks? | `.config/<concern>/` | `markdown/markdownlint.jsonc`, `yaml/yamllint.yaml` (`lefthook.yml` top-level) |
| 3 | Does it provision the container or its services? | `.devcontainer/` | `Dockerfile`, `devcontainer.json`, `mise.toml`, `stacks/` |
| 4 | Does it enforce repo structure? | `.repo/` | The `repo` CLI and its policies |

Expand All @@ -67,8 +67,12 @@ other infrastructure directories this repo already has — `.devcontainer/`,
`.github/`, `.repo/`. What is visible at the root is content you edit; what is
dotted is machinery that operates on it.

Two rules make the `.config/` home hold:
Three rules make the `.config/` home hold:

- **Bucket by concern.** `.config/<concern>/<tool>.<ext>` — a one-file bucket
is fine and collects siblings over time. The single exception is
`lefthook.yml`, which sits at the top level because lefthook's config search
does not descend past `.config/lefthook.*`.
- **Pass the config path explicitly.** Every caller names its config with the
tool's own flag (`--config`, `-c`, `-config-file`). The single exception is
lefthook, which searches `.config/` natively. Relying on default discovery is
Expand All @@ -88,10 +92,10 @@ See [`.config/README.md`](.config/README.md) for the per-file index, and
| | CLIs with no Feature (Codex, Lefthook) | `.devcontainer/mise.toml` |
| | Self-updating CLIs (Claude Code) | `scripts/lib/base-setup.sh` |
| **Tooling** | Git hooks | `.config/lefthook.yml` |
| | Markdown lint rules | `.config/markdownlint.jsonc` |
| | YAML lint rules | `.config/yamllint.yaml` |
| | GitHub Actions lint rules | `.config/actionlint.yaml` |
| | Spelling dictionary / ignores | `.config/codespell.cfg` |
| | Markdown lint rules | `.config/markdown/markdownlint.jsonc` |
| | YAML lint rules | `.config/yaml/yamllint.yaml` |
| | GitHub Actions lint rules | `.config/actions/actionlint.yaml` |
| | Spelling dictionary / ignores | `.config/spelling/codespell.cfg` |
| | Task automation for the template | `Taskfile.yml` + `taskfiles/<name>.Taskfile.yml` |
| | Repo structure policies | `.repo/governance/policies/` |
| **Editor** | VS Code settings (formatters, rulers, whitespace) | `devcontainer.json` → `customizations.vscode.settings` |
Expand Down Expand Up @@ -562,12 +566,12 @@ settings across container rebuilds.
```text
.config/ Tool configuration (see "Where configuration lives")
README.md Index: every file, its tool, and how it is reached
lefthook.yml Git hooks (auto-discovered by lefthook)
lefthook.yml Git hooks (top-level: lefthook's search stops at .config/lefthook.*)
lefthook-local.yml Personal hook overrides (gitignored, auto-merged)
markdownlint.jsonc Markdown rules (--config)
yamllint.yaml YAML rules (--config)
actionlint.yaml Workflow rules (-config-file)
codespell.cfg Spelling (--config)
markdown/markdownlint.jsonc Markdown rules (--config)
yaml/yamllint.yaml YAML rules (--config)
actions/actionlint.yaml Workflow rules (-config-file)
spelling/codespell.cfg Spelling (--config)
.repo/ Repo governance toolchain (the `repo` CLI)
README.md What each policy enforces, and why
pyproject.toml uv project; declares the `repo` console-script
Expand Down
16 changes: 8 additions & 8 deletions taskfiles/lint.Taskfile.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,10 @@ version: '3'
# Policy: CONFIGURATION.md → "Where Configuration Lives".

vars:
MD_CONFIG: .config/markdownlint.jsonc
YAML_CONFIG: .config/yamllint.yaml
ACTIONLINT_CONFIG: .config/actionlint.yaml
CODESPELL_CONFIG: .config/codespell.cfg
MD_CONFIG: .config/markdown/markdownlint.jsonc
YAML_CONFIG: .config/yaml/yamllint.yaml
ACTIONLINT_CONFIG: .config/actions/actionlint.yaml
CODESPELL_CONFIG: .config/spelling/codespell.cfg
# Directories that contain YAML this repo owns.
YAML_PATHS: .github .devcontainer .config taskfiles Taskfile.yml

Expand All @@ -25,7 +25,7 @@ tasks:
- task: spelling

md:
desc: Lint Markdown against .config/markdownlint.jsonc.
desc: Lint Markdown against .config/markdown/markdownlint.jsonc.
cmds:
- markdownlint-cli2 --config {{.MD_CONFIG}} "**/*.md" "#node_modules" "#.repo/.venv"

Expand All @@ -35,17 +35,17 @@ tasks:
- markdownlint-cli2 --config {{.MD_CONFIG}} --fix "**/*.md" "#node_modules" "#.repo/.venv"

yaml:
desc: Lint YAML against .config/yamllint.yaml.
desc: Lint YAML against .config/yaml/yamllint.yaml.
cmds:
- yamllint -c {{.YAML_CONFIG}} {{.YAML_PATHS}}

actions:
desc: Lint GitHub Actions workflows against .config/actionlint.yaml.
desc: Lint GitHub Actions workflows against .config/actions/actionlint.yaml.
cmds:
- actionlint -config-file {{.ACTIONLINT_CONFIG}}

spelling:
desc: Spellcheck the repo against .config/codespell.cfg.
desc: Spellcheck the repo against .config/spelling/codespell.cfg.
cmds:
- codespell --config {{.CODESPELL_CONFIG}} .

Expand Down