From 209f5255cb02b327369ea0e357e957046f7833b4 Mon Sep 17 00:00:00 2001 From: Justin Merrell Date: Wed, 19 Aug 2026 06:48:02 +0700 Subject: [PATCH] feat(config): adopt platform's .config// bucket layout Mirror musher-dev/platform's converged convention: tool configs live in concern buckets (actions/, markdown/, spelling/, yaml/), with lefthook.yml alone at the top level -- lefthook's config search does not descend past .config/lefthook.*, so bucketing it would silently disable every hook. The governance policy now walks .config/ recursively, keys the index and caller checks on bucket-relative paths, extends CFG-07 to files misplaced at the .config/ top level, and adds CFG-08 rejecting executables -- parity with platform's CFG-01..CFG-08. Co-Authored-By: Claude Fable 5 --- .config/README.md | 18 +++--- .config/{ => actions}/actionlint.yaml | 0 .config/lefthook.yml | 12 ++-- .config/{ => markdown}/markdownlint.jsonc | 0 .config/{ => spelling}/codespell.cfg | 0 .config/{ => yaml}/yamllint.yaml | 0 .repo/README.md | 2 +- .repo/governance/policies/config/check.py | 55 ++++++++++++++++--- .../governance/policies/config/violations.py | 55 +++++++++++++++---- CONFIGURATION.md | 28 ++++++---- taskfiles/lint.Taskfile.yml | 16 +++--- 11 files changed, 131 insertions(+), 55 deletions(-) rename .config/{ => actions}/actionlint.yaml (100%) rename .config/{ => markdown}/markdownlint.jsonc (100%) rename .config/{ => spelling}/codespell.cfg (100%) rename .config/{ => yaml}/yamllint.yaml (100%) diff --git a/.config/README.md b/.config/README.md index 754dca9..be2d7c2 100644 --- a/.config/README.md +++ b/.config/README.md @@ -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). @@ -26,9 +26,11 @@ from that same file via `jdx/mise-action`. ## Rules -1. **Flat.** `.config/.`. Create a `.config//` 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//.`. 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 @@ -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 diff --git a/.config/actionlint.yaml b/.config/actions/actionlint.yaml similarity index 100% rename from .config/actionlint.yaml rename to .config/actions/actionlint.yaml diff --git a/.config/lefthook.yml b/.config/lefthook.yml index 2a99f81..d4a7b95 100644 --- a/.config/lefthook.yml +++ b/.config/lefthook.yml @@ -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 @@ -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. diff --git a/.config/markdownlint.jsonc b/.config/markdown/markdownlint.jsonc similarity index 100% rename from .config/markdownlint.jsonc rename to .config/markdown/markdownlint.jsonc diff --git a/.config/codespell.cfg b/.config/spelling/codespell.cfg similarity index 100% rename from .config/codespell.cfg rename to .config/spelling/codespell.cfg diff --git a/.config/yamllint.yaml b/.config/yaml/yamllint.yaml similarity index 100% rename from .config/yamllint.yaml rename to .config/yaml/yamllint.yaml diff --git a/.repo/README.md b/.repo/README.md index 8567fff..97c5035 100644 --- a/.repo/README.md +++ b/.repo/README.md @@ -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//` 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 | diff --git a/.repo/governance/policies/config/check.py b/.repo/governance/policies/config/check.py index 0a4e690..2a899d9 100644 --- a/.repo/governance/policies/config/check.py +++ b/.repo/governance/policies/config/check.py @@ -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 @@ -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", @@ -38,7 +56,7 @@ ".stylelintrc", ".shellcheckrc", ) -#: Files scanned for explicit `.config/` references. +#: Files scanned for explicit `.config/` references. CALLER_GLOBS = ( "Taskfile.yml", "taskfiles/*.yml", @@ -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 = [] @@ -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(): diff --git a/.repo/governance/policies/config/violations.py b/.repo/governance/policies/config/violations.py index 85c3eda..0c45c97 100644 --- a/.repo/governance/policies/config/violations.py +++ b/.repo/governance/policies/config/violations.py @@ -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, ) @@ -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// 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//.. 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// 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, + ) diff --git a/CONFIGURATION.md b/CONFIGURATION.md index f149ce9..8e2bd5a 100644 --- a/CONFIGURATION.md +++ b/CONFIGURATION.md @@ -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/. + → .config//. (lefthook.yml stays at .config/ top level) Does it provision the container itself? → .devcontainer/ (see the branches below) @@ -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//` | `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 | @@ -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//.` — 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 @@ -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/.Taskfile.yml` | | | Repo structure policies | `.repo/governance/policies/` | | **Editor** | VS Code settings (formatters, rulers, whitespace) | `devcontainer.json` → `customizations.vscode.settings` | @@ -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 diff --git a/taskfiles/lint.Taskfile.yml b/taskfiles/lint.Taskfile.yml index 0738d34..cac3979 100644 --- a/taskfiles/lint.Taskfile.yml +++ b/taskfiles/lint.Taskfile.yml @@ -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 @@ -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" @@ -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}} .