@theglitchking/babel-fish 2.5.0 → 2.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/install.sh +38 -2
- package/.claude/project-map/generate.py +25 -0
- package/.claude/project-map/structure-check.py +773 -0
- package/.claude/project-map/test_generate.py +13 -0
- package/.claude/project-map/test_structure_check.py +864 -0
- package/.claude/templates/structure/monorepo.toml +76 -0
- package/.claude/templates/structure/multi-repo.toml +85 -0
- package/.claude/templates/structure/single.toml +73 -0
- package/.claude-plugin/marketplace.json +2 -2
- package/.claude-plugin/plugin.json +1 -1
- package/.githooks/pre-commit +18 -0
- package/CHANGELOG.md +57 -0
- package/README.md +31 -2
- package/checksums.json +1 -1
- package/package.json +4 -2
- package/scripts/link-skills.js +3 -3
- package/skills/structure-bootstrap/SKILL.md +209 -0
|
@@ -0,0 +1,773 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
structure-check.py — Babel Fish
|
|
4
|
+
Keeps files where the repo says they go (#22).
|
|
5
|
+
|
|
6
|
+
The project map describes where things ARE. `.claude/structure.toml` says where
|
|
7
|
+
things SHOULD go: every approved folder, what each may hold, its lifecycle
|
|
8
|
+
(planned → active → deprecated), and the dev → stg → prod environments. This
|
|
9
|
+
script checks the tracked tree against it.
|
|
10
|
+
|
|
11
|
+
Opt-in: with no manifest it only explains the options and exits 0. The
|
|
12
|
+
pre-commit hook runs it only when the manifest exists.
|
|
13
|
+
|
|
14
|
+
Deliberately NOT part of generate.py: the hook runs generate.py only when code
|
|
15
|
+
is staged and discards its errors — a failure there could never block a commit.
|
|
16
|
+
|
|
17
|
+
Usage:
|
|
18
|
+
python structure-check.py [--staged | --since REF] [--warn-only] [--project-root PATH]
|
|
19
|
+
python structure-check.py --detect [--json]
|
|
20
|
+
python structure-check.py --bootstrap [--layout NAME] [--mode MODE]
|
|
21
|
+
|
|
22
|
+
Exit: 0 clean (or no manifest), 1 violations, 2 unusable manifest or not a git repo.
|
|
23
|
+
"""
|
|
24
|
+
from __future__ import annotations
|
|
25
|
+
|
|
26
|
+
import argparse
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
from datetime import date
|
|
32
|
+
from functools import lru_cache
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
try:
|
|
36
|
+
import tomllib
|
|
37
|
+
except ImportError: # ponytail: only this script needs 3.11; the rest of babel-fish keeps 3.8
|
|
38
|
+
tomllib = None
|
|
39
|
+
|
|
40
|
+
PROJECT_ROOT = Path(__file__).parent.parent.parent
|
|
41
|
+
MANIFEST = PROJECT_ROOT / ".claude" / "structure.toml"
|
|
42
|
+
# Layout templates ship with the scripts (install.sh copies .claude/templates/).
|
|
43
|
+
LAYOUTS_DIR = Path(__file__).parent.parent / "templates" / "structure"
|
|
44
|
+
|
|
45
|
+
MODES = ("monorepo", "single", "multi-repo")
|
|
46
|
+
STATUSES = ("planned", "active", "deprecated")
|
|
47
|
+
TARGETS = ("local", "cloud")
|
|
48
|
+
|
|
49
|
+
# Environments. An env folder is <env_root>/<env name>/; siblings named below hold
|
|
50
|
+
# what every environment shares (base + overlay).
|
|
51
|
+
DEFAULT_ENV_ROOTS = ["infra/env", "infra/deploy"]
|
|
52
|
+
SHARED_ENV_DIRS = {"base", "shared", "common", "modules"}
|
|
53
|
+
ENV_PARENT_DIRS = {"env", "envs", "environments", "overlays", "config"} # <parent>/<env>/ outside env_roots
|
|
54
|
+
MIGRATION_DIRS = {"migrations", "migration", "alembic"}
|
|
55
|
+
# .env files: ONE committed template (env_file, always named .env.example) holding every key for every
|
|
56
|
+
# environment and app, and ONE real .env beside it, gitignored. Stg/prod values come from the secret
|
|
57
|
+
# manager or CI, never from more files in the repo.
|
|
58
|
+
TEMPLATE_SUFFIXES = (".example", ".sample", ".template", ".dist")
|
|
59
|
+
TEMPLATE_WORDS = {"example", "sample", "template", "dist"}
|
|
60
|
+
# ponytail: fixed lists — extend here when a real repo needs another platform
|
|
61
|
+
LOCAL_ONLY = ["**/*compose*.yml", "**/*compose*.yaml", "**/seed*", "**/seeds/**",
|
|
62
|
+
"**/fixtures/**", "**/*.pem", "**/*.crt", "**/*.key"]
|
|
63
|
+
DEPLOY_ONLY = ["**/*.tf", "**/*.tfvars", "**/*.hcl", "**/kustomization.yaml", "**/kustomization.yml",
|
|
64
|
+
"**/Chart.yaml", "**/fly.toml", "**/render.yaml", "**/vercel.json", "**/railway.json"]
|
|
65
|
+
EMPTY_BLOB = "e69de29bb2d1d6434b8b29ae775ad8c2e48c5391" # identical empty files are not copies
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def configure_paths(project_root: Path) -> None:
|
|
69
|
+
"""Rebind every root-derived path (#18: never set PROJECT_ROOT alone)."""
|
|
70
|
+
global PROJECT_ROOT, MANIFEST
|
|
71
|
+
PROJECT_ROOT = project_root
|
|
72
|
+
MANIFEST = project_root / ".claude" / "structure.toml"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ── globs ────────────────────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
@lru_cache(maxsize=None)
|
|
78
|
+
def glob_re(pattern: str) -> re.Pattern:
|
|
79
|
+
"""Path glob: `*` and `?` stop at `/`, `**` crosses it, `**/` may match nothing."""
|
|
80
|
+
out, i = [], 0
|
|
81
|
+
while i < len(pattern):
|
|
82
|
+
if pattern.startswith("**/", i):
|
|
83
|
+
out.append("(?:.*/)?")
|
|
84
|
+
i += 3
|
|
85
|
+
elif pattern.startswith("**", i):
|
|
86
|
+
out.append(".*")
|
|
87
|
+
i += 2
|
|
88
|
+
elif pattern[i] == "*":
|
|
89
|
+
out.append("[^/]*")
|
|
90
|
+
i += 1
|
|
91
|
+
elif pattern[i] == "?":
|
|
92
|
+
out.append("[^/]")
|
|
93
|
+
i += 1
|
|
94
|
+
else:
|
|
95
|
+
out.append(re.escape(pattern[i]))
|
|
96
|
+
i += 1
|
|
97
|
+
return re.compile("".join(out) + r"\Z")
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def matches(path: str, patterns) -> bool:
|
|
101
|
+
return any(glob_re(p).match(path) for p in patterns)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
# ── git ──────────────────────────────────────────────────────────────────────
|
|
105
|
+
|
|
106
|
+
def git(*args: str) -> str:
|
|
107
|
+
r = subprocess.run(["git", *args], cwd=PROJECT_ROOT, capture_output=True, text=True)
|
|
108
|
+
if r.returncode:
|
|
109
|
+
raise RuntimeError(f"git {' '.join(args)}: {r.stderr.strip()}")
|
|
110
|
+
return r.stdout
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def is_git_repo() -> bool:
|
|
114
|
+
try:
|
|
115
|
+
return git("rev-parse", "--is-inside-work-tree").strip() == "true"
|
|
116
|
+
except (RuntimeError, OSError):
|
|
117
|
+
return False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
# ── manifest ─────────────────────────────────────────────────────────────────
|
|
121
|
+
|
|
122
|
+
def load_manifest(path: Path) -> dict:
|
|
123
|
+
with open(path, "rb") as f:
|
|
124
|
+
m = tomllib.load(f)
|
|
125
|
+
for f in m.get("folder", []):
|
|
126
|
+
if isinstance(f.get("path"), str):
|
|
127
|
+
f["path"] = f["path"].strip("/")
|
|
128
|
+
return m
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _str_list(v) -> bool:
|
|
132
|
+
return isinstance(v, list) and all(isinstance(x, str) for x in v)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def validate(m: dict) -> list[str]:
|
|
136
|
+
"""-> problems that make the manifest unusable (exit 2)."""
|
|
137
|
+
errs = []
|
|
138
|
+
if m.get("mode", "single") not in MODES:
|
|
139
|
+
errs.append(f"mode = {m.get('mode')!r}; expected one of {', '.join(MODES)}")
|
|
140
|
+
for key in ("root_files", "exceptions", "env_roots"):
|
|
141
|
+
if key in m and not _str_list(m[key]):
|
|
142
|
+
errs.append(f"{key} must be a list of strings")
|
|
143
|
+
if "env_file" in m and (not isinstance(m["env_file"], str) or m["env_file"].rsplit("/", 1)[-1] != ".env.example"):
|
|
144
|
+
errs.append(f"env_file = {m['env_file']!r}; must be a path to a file named .env.example")
|
|
145
|
+
|
|
146
|
+
seen = set()
|
|
147
|
+
for i, f in enumerate(m.get("folder", [])):
|
|
148
|
+
where = f"[[folder]] #{i + 1}"
|
|
149
|
+
p = f.get("path")
|
|
150
|
+
if not isinstance(p, str) or not p:
|
|
151
|
+
errs.append(f"{where}: path is required")
|
|
152
|
+
continue
|
|
153
|
+
where = f"[[folder]] {p}"
|
|
154
|
+
if p in seen:
|
|
155
|
+
errs.append(f"{where}: listed twice")
|
|
156
|
+
seen.add(p)
|
|
157
|
+
if f.get("status", "active") not in STATUSES:
|
|
158
|
+
errs.append(f"{where}: status = {f.get('status')!r}; expected one of {', '.join(STATUSES)}")
|
|
159
|
+
if "holds" in f and not _str_list(f["holds"]):
|
|
160
|
+
errs.append(f"{where}: holds must be a list of glob strings")
|
|
161
|
+
|
|
162
|
+
envs = m.get("environment", [])
|
|
163
|
+
names = [e.get("name") for e in envs]
|
|
164
|
+
for e in envs:
|
|
165
|
+
where = f"[[environment]] {e.get('name', '?')}"
|
|
166
|
+
if not isinstance(e.get("name"), str) or not e["name"]:
|
|
167
|
+
errs.append(f"{where}: name is required")
|
|
168
|
+
if e.get("target") not in TARGETS:
|
|
169
|
+
errs.append(f"{where}: target = {e.get('target')!r}; expected one of {', '.join(TARGETS)}")
|
|
170
|
+
for key in ("promotes_to", "mirrors"):
|
|
171
|
+
if key in e and e[key] not in names:
|
|
172
|
+
errs.append(f"{where}: {key} = {e[key]!r} is not a declared environment")
|
|
173
|
+
if len(set(names)) != len(names):
|
|
174
|
+
errs.append("[[environment]]: a name is declared twice")
|
|
175
|
+
nxt = {e.get("name"): e.get("promotes_to") for e in envs}
|
|
176
|
+
for start in nxt:
|
|
177
|
+
cur, hops = start, 0
|
|
178
|
+
while cur in nxt and nxt[cur] and hops <= len(nxt):
|
|
179
|
+
cur, hops = nxt[cur], hops + 1
|
|
180
|
+
if hops > len(nxt):
|
|
181
|
+
errs.append(f"[[environment]]: promotes_to loops back through {start!r}")
|
|
182
|
+
break
|
|
183
|
+
|
|
184
|
+
for i, r in enumerate(m.get("repo", [])):
|
|
185
|
+
if not isinstance(r.get("name"), str) or not r["name"]:
|
|
186
|
+
errs.append(f"[[repo]] #{i + 1}: name is required")
|
|
187
|
+
if not (r.get("path") or r.get("url")):
|
|
188
|
+
errs.append(f"[[repo]] {r.get('name', '#' + str(i + 1))}: needs a path or a url")
|
|
189
|
+
return errs
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ── checks ───────────────────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
def tracked() -> dict[str, str]:
|
|
195
|
+
"""-> {path: blob sha} for every file in the index (what the next commit holds)."""
|
|
196
|
+
out = {}
|
|
197
|
+
for entry in git("ls-files", "-s", "-z").split("\0"):
|
|
198
|
+
if entry:
|
|
199
|
+
meta, path = entry.split("\t", 1)
|
|
200
|
+
out[path] = meta.split()[1]
|
|
201
|
+
return out
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def added(staged: bool, since: str | None) -> set[str]:
|
|
205
|
+
"""-> paths new in this change: staged adds, or adds since REF (CI). Renames count as adds."""
|
|
206
|
+
if staged:
|
|
207
|
+
out = git("diff", "--cached", "--name-only", "--no-renames", "--diff-filter=A", "-z")
|
|
208
|
+
elif since:
|
|
209
|
+
try:
|
|
210
|
+
out = git("diff", "--name-only", "--no-renames", "--diff-filter=A", "-z", f"{since}...HEAD")
|
|
211
|
+
except RuntimeError as e:
|
|
212
|
+
raise RuntimeError(f"{e} -- --since needs {since} and its merge base with HEAD; in CI fetch full "
|
|
213
|
+
"history (actions/checkout with fetch-depth: 0)") from None
|
|
214
|
+
else:
|
|
215
|
+
return set()
|
|
216
|
+
return {p for p in out.split("\0") if p}
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class Finding:
|
|
220
|
+
def __init__(self, level: str, path: str, msg: str, exemptable: bool = True):
|
|
221
|
+
self.level, self.path, self.msg, self.exemptable = level, path, msg, exemptable
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def owner(path: str, folders: list[dict]) -> dict | None:
|
|
225
|
+
"""-> the deepest manifest folder containing `path` (folders pre-sorted deepest first)."""
|
|
226
|
+
for f in folders:
|
|
227
|
+
if path.startswith(f["path"] + "/"):
|
|
228
|
+
return f
|
|
229
|
+
return None
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def check_folders(m: dict, files: dict[str, str], new: set[str]) -> list[Finding]:
|
|
233
|
+
folders = sorted(m.get("folder", []), key=lambda f: -f["path"].count("/") - len(f["path"]))
|
|
234
|
+
root_files = m.get("root_files", [])
|
|
235
|
+
out, used = [], set()
|
|
236
|
+
approved = {".claude/structure.toml", env_file_path(m)} # the manifest and the one env template
|
|
237
|
+
for path in sorted(files):
|
|
238
|
+
if path in approved: # declared by the manifest itself; never has to be listed again
|
|
239
|
+
continue
|
|
240
|
+
f = owner(path, folders)
|
|
241
|
+
if f is None:
|
|
242
|
+
if "/" not in path:
|
|
243
|
+
if not matches(path, root_files):
|
|
244
|
+
out.append(Finding("FAIL", path, "root file not in root_files -- add it there, or move it into a folder"))
|
|
245
|
+
else:
|
|
246
|
+
out.append(Finding("FAIL", path, "under no manifest folder -- move it into one, or add a [[folder]] for it"))
|
|
247
|
+
continue
|
|
248
|
+
used.add(f["path"])
|
|
249
|
+
rel = path[len(f["path"]) + 1:]
|
|
250
|
+
if "holds" in f and not matches(rel, f["holds"]):
|
|
251
|
+
out.append(Finding("FAIL", path, f"doesn't match {f['path']}/ holds {f['holds']} -- move it, or widen holds"))
|
|
252
|
+
if f.get("status") == "deprecated" and path in new:
|
|
253
|
+
out.append(Finding("FAIL", path, f"new file in deprecated folder {f['path']}/ -- put it where its replacement lives"))
|
|
254
|
+
|
|
255
|
+
for f in folders:
|
|
256
|
+
status, p = f.get("status", "active"), f["path"]
|
|
257
|
+
if status == "planned" and p in used:
|
|
258
|
+
out.append(Finding("WARN", p + "/", "planned folder now has files -- set status = \"active\""))
|
|
259
|
+
elif status == "active" and p not in used:
|
|
260
|
+
out.append(Finding("WARN", p + "/", "active folder has no tracked files -- planned, or finished retiring?"))
|
|
261
|
+
elif status == "deprecated" and p not in used:
|
|
262
|
+
out.append(Finding("WARN", p + "/", "deprecated folder is empty -- remove its [[folder]] entry (and the folder) in one commit"))
|
|
263
|
+
return out
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def env_kind(name: str) -> str | None:
|
|
267
|
+
"""-> "secret" (real values), "template" (keys + placeholders) or None (not an env file)."""
|
|
268
|
+
if name.endswith(TEMPLATE_SUFFIXES) and (name.startswith((".env.", "env.")) or ".env." in name):
|
|
269
|
+
return "template" # .env.example, .env.prod.example, env.sample, api.env.example
|
|
270
|
+
if name.endswith(".env") and name.split(".")[0] in TEMPLATE_WORDS:
|
|
271
|
+
return "template" # example.env
|
|
272
|
+
if name == ".env" or name.startswith(".env.") or name.endswith(".env"):
|
|
273
|
+
return "secret" # .env, .env.local, .env.production, prod.env
|
|
274
|
+
return None
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def env_file_path(m: dict) -> str:
|
|
278
|
+
"""The one committed template: env_file, else <first env_root>/.env.example with environments, else root."""
|
|
279
|
+
if m.get("env_file"):
|
|
280
|
+
return m["env_file"].strip("/")
|
|
281
|
+
if m.get("environment"):
|
|
282
|
+
return m.get("env_roots", DEFAULT_ENV_ROOTS)[0].strip("/") + "/.env.example"
|
|
283
|
+
return ".env.example"
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
def check_secrets(files) -> list[Finding]:
|
|
287
|
+
"""A real .env is never committed, in any mode; only an exact-path exception allows one."""
|
|
288
|
+
out = []
|
|
289
|
+
for path in files:
|
|
290
|
+
if env_kind(path.rsplit("/", 1)[-1]) != "secret":
|
|
291
|
+
continue
|
|
292
|
+
out.append(Finding("FAIL", path, "secrets file is tracked -- git rm --cached it, keep values in "
|
|
293
|
+
"the environment or a secret manager, commit only .env.example (a committed "
|
|
294
|
+
"test fixture with no real secrets: list its exact path in exceptions)",
|
|
295
|
+
exemptable=False))
|
|
296
|
+
return out
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def check_env_files(m: dict, files: dict[str, str]) -> list[Finding]:
|
|
300
|
+
"""As close to a single .env as the structure allows: one template, one real file beside it."""
|
|
301
|
+
want = env_file_path(m)
|
|
302
|
+
home = want.rsplit("/", 1)[0] if "/" in want else ""
|
|
303
|
+
real = f"{home}/.env" if home else ".env"
|
|
304
|
+
out = check_secrets(files)
|
|
305
|
+
for path in sorted(files):
|
|
306
|
+
if env_kind(path.rsplit("/", 1)[-1]) != "template" or path == want:
|
|
307
|
+
continue
|
|
308
|
+
here = path.rsplit("/", 1)[0] if "/" in path else ""
|
|
309
|
+
if here == home:
|
|
310
|
+
out.append(Finding("FAIL", path, f"env template named differently -- rename it to {want}"))
|
|
311
|
+
else:
|
|
312
|
+
out.append(Finding("FAIL", path, f"scattered env template -- merge its keys into {want} and delete "
|
|
313
|
+
"this one (one template covers every environment and app; an app that "
|
|
314
|
+
f"expects its own .env loads {real} via --env-file / dotenv, or a symlink)"))
|
|
315
|
+
|
|
316
|
+
# The working tree, not the index: this is what stops a real .env being committed in the first
|
|
317
|
+
# place. In CI the tree is a clean checkout, so these find nothing.
|
|
318
|
+
for path in git("ls-files", "--others", "--exclude-standard", "-z").split("\0"):
|
|
319
|
+
if path and env_kind(path.rsplit("/", 1)[-1]) == "secret":
|
|
320
|
+
out.append(Finding("FAIL", path, "real env file is not gitignored -- `git add -A` would commit it: "
|
|
321
|
+
"add `.env` and `.env.*` (with `!.env.example`) to .gitignore", exemptable=False))
|
|
322
|
+
for path in git("ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z").split("\0"):
|
|
323
|
+
if path and not path.endswith("/") and path != real and env_kind(path.rsplit("/", 1)[-1]) == "secret":
|
|
324
|
+
out.append(Finding("WARN", path, f"local env file outside {real} -- merge its values into {real} "
|
|
325
|
+
"and delete it (one real .env; stg/prod values live in the secret manager)"))
|
|
326
|
+
return out
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def check_environments(m: dict, files: dict[str, str]) -> list[Finding]:
|
|
330
|
+
"""dev → stg → prod: declared envs only, deltas only, stg mirrors prod, local vs cloud kept apart."""
|
|
331
|
+
envs = {e["name"]: e for e in m.get("environment", [])}
|
|
332
|
+
if not envs:
|
|
333
|
+
return []
|
|
334
|
+
roots = [r.strip("/") for r in m.get("env_roots", DEFAULT_ENV_ROOTS)]
|
|
335
|
+
out, undeclared = [], set()
|
|
336
|
+
env_files: dict[tuple[str, str], set[str]] = {} # (root, env) -> paths relative to root/env
|
|
337
|
+
in_env = set()
|
|
338
|
+
|
|
339
|
+
for path in sorted(files):
|
|
340
|
+
root = next((r for r in roots if path.startswith(r + "/")), None)
|
|
341
|
+
parts = path.split("/")
|
|
342
|
+
dirs = parts[:-1]
|
|
343
|
+
if root is None:
|
|
344
|
+
for i in range(1, len(dirs)):
|
|
345
|
+
if dirs[i] in envs and dirs[i - 1] in ENV_PARENT_DIRS:
|
|
346
|
+
d = "/".join(dirs[:i + 1])
|
|
347
|
+
if d not in undeclared:
|
|
348
|
+
undeclared.add(d)
|
|
349
|
+
out.append(Finding("FAIL", d + "/", f"per-environment folder outside env_roots {roots} -- "
|
|
350
|
+
f"move its deltas to <env_root>/{dirs[i]}/, or add its parent to env_roots"))
|
|
351
|
+
if set(dirs) & MIGRATION_DIRS and set(dirs) & set(envs):
|
|
352
|
+
out.append(Finding("FAIL", path, "per-environment migration -- migrations are shared: one folder, "
|
|
353
|
+
"the same path forward in every environment"))
|
|
354
|
+
continue
|
|
355
|
+
sub = path[len(root) + 1:]
|
|
356
|
+
if "/" not in sub:
|
|
357
|
+
continue # shared file directly in the env root
|
|
358
|
+
env, rel = sub.split("/", 1)
|
|
359
|
+
if env in SHARED_ENV_DIRS:
|
|
360
|
+
continue
|
|
361
|
+
if env not in envs:
|
|
362
|
+
d = f"{root}/{env}"
|
|
363
|
+
if d not in undeclared:
|
|
364
|
+
undeclared.add(d)
|
|
365
|
+
out.append(Finding("FAIL", d + "/", f"environment {env!r} is not declared -- add an [[environment]], "
|
|
366
|
+
"or fold it into a declared one"))
|
|
367
|
+
continue
|
|
368
|
+
in_env.add(path)
|
|
369
|
+
env_files.setdefault((root, env), set()).add(rel)
|
|
370
|
+
target = envs[env]["target"]
|
|
371
|
+
if target == "cloud" and matches(rel, LOCAL_ONLY):
|
|
372
|
+
out.append(Finding("FAIL", path, f"local-only file in cloud environment {env!r} -- compose overrides, "
|
|
373
|
+
"seeds and local certs belong to a local environment"))
|
|
374
|
+
elif target == "local" and matches(rel, DEPLOY_ONLY):
|
|
375
|
+
out.append(Finding("FAIL", path, f"deploy config in local environment {env!r} -- Terraform, k8s and "
|
|
376
|
+
"platform files belong to a cloud environment"))
|
|
377
|
+
if set(rel.split("/")[:-1]) & MIGRATION_DIRS:
|
|
378
|
+
out.append(Finding("FAIL", path, "per-environment migration -- migrations are shared: one folder, "
|
|
379
|
+
"the same path forward in every environment"))
|
|
380
|
+
|
|
381
|
+
for e in envs.values():
|
|
382
|
+
m_env = e.get("mirrors")
|
|
383
|
+
if not m_env:
|
|
384
|
+
continue
|
|
385
|
+
for root in roots:
|
|
386
|
+
a, b = env_files.get((root, e["name"]), set()), env_files.get((root, m_env), set())
|
|
387
|
+
for rel in sorted(b - a):
|
|
388
|
+
out.append(Finding("FAIL", f"{root}/{e['name']}/{rel}", f"missing: {e['name']} mirrors {m_env}, "
|
|
389
|
+
f"which has {root}/{m_env}/{rel} -- add it, or remove it from {m_env}"))
|
|
390
|
+
for rel in sorted(a - b):
|
|
391
|
+
out.append(Finding("FAIL", f"{root}/{m_env}/{rel}", f"missing: {e['name']} mirrors {m_env} and has "
|
|
392
|
+
f"{root}/{e['name']}/{rel} -- add it to {m_env}, or remove it from {e['name']}"))
|
|
393
|
+
|
|
394
|
+
shared = {sha: p for p, sha in files.items() if p not in in_env and sha != EMPTY_BLOB}
|
|
395
|
+
for path in sorted(in_env):
|
|
396
|
+
if files[path] in shared:
|
|
397
|
+
out.append(Finding("FAIL", path, f"identical copy of {shared[files[path]]} -- an environment folder "
|
|
398
|
+
"holds only what differs; reference the shared file instead"))
|
|
399
|
+
|
|
400
|
+
token = re.compile(r"(?<![A-Za-z0-9])(" + "|".join(map(re.escape, envs)) + r")(?![A-Za-z0-9])")
|
|
401
|
+
groups: dict[str, list[str]] = {}
|
|
402
|
+
for path in files:
|
|
403
|
+
if path.startswith(".github/workflows/") and token.search(path):
|
|
404
|
+
groups.setdefault(token.sub("{env}", path), []).append(path)
|
|
405
|
+
for tmpl, paths in sorted(groups.items()):
|
|
406
|
+
if len(paths) > 1:
|
|
407
|
+
out.append(Finding("WARN", tmpl, f"per-environment workflow copies ({', '.join(sorted(paths))}) -- "
|
|
408
|
+
"prefer one workflow that takes the environment as input"))
|
|
409
|
+
return out
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def check_repos(m: dict) -> list[Finding]:
|
|
413
|
+
out = []
|
|
414
|
+
for r in m.get("repo", []):
|
|
415
|
+
if r.get("path") and not (PROJECT_ROOT / r["path"] / ".git").exists():
|
|
416
|
+
out.append(Finding("WARN", r["path"], f"[[repo]] {r['name']}: no git checkout here -- clone it or fix the path"))
|
|
417
|
+
return out
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def apply_exceptions(findings: list[Finding], exceptions: list[str]) -> tuple[list[Finding], int]:
|
|
421
|
+
"""Drop FAILs the baseline allows; WARN on baseline entries that allow nothing anymore."""
|
|
422
|
+
kept, hit, baselined = [], set(), 0
|
|
423
|
+
for f in findings:
|
|
424
|
+
if f.level != "FAIL":
|
|
425
|
+
kept.append(f)
|
|
426
|
+
continue
|
|
427
|
+
# A secrets FAIL yields only to its exact path: a glob must never wave through a new .env.
|
|
428
|
+
pats = [e for e in exceptions if (glob_re(e).match(f.path) if f.exemptable else e == f.path)]
|
|
429
|
+
if pats:
|
|
430
|
+
hit.update(pats)
|
|
431
|
+
baselined += 1
|
|
432
|
+
else:
|
|
433
|
+
kept.append(f)
|
|
434
|
+
for e in exceptions:
|
|
435
|
+
if e not in hit:
|
|
436
|
+
kept.append(Finding("WARN", e, "exception no longer needed -- remove it from exceptions"))
|
|
437
|
+
return kept, baselined
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def run(m: dict, staged: bool, since: str | None) -> int:
|
|
441
|
+
"""Print the report; return the number of FAILs."""
|
|
442
|
+
files = tracked()
|
|
443
|
+
findings = (check_folders(m, files, added(staged, since)) + check_environments(m, files)
|
|
444
|
+
+ check_env_files(m, files) + check_repos(m))
|
|
445
|
+
hard = {f.path for f in findings if not f.exemptable} # a secret is reported as a secret, not twice
|
|
446
|
+
findings = [f for f in findings if not f.exemptable or f.path not in hard]
|
|
447
|
+
findings, baselined = apply_exceptions(findings, m.get("exceptions", []))
|
|
448
|
+
fails = [f for f in findings if f.level == "FAIL"]
|
|
449
|
+
warns = [f for f in findings if f.level == "WARN"]
|
|
450
|
+
|
|
451
|
+
print(f"structure-check -- .claude/structure.toml (mode: {m.get('mode', 'single')})")
|
|
452
|
+
for f in fails + warns:
|
|
453
|
+
print(f" {f.level} {f.path} -- {f.msg.split(' -- ', 1)[0]}")
|
|
454
|
+
if " -- " in f.msg:
|
|
455
|
+
print(f" fix: {f.msg.split(' -- ', 1)[1]}")
|
|
456
|
+
print(f"\n{len(files)} files checked" + (f", {baselined} allowed by exceptions" if baselined else ""))
|
|
457
|
+
print("OK" if not fails else f"{len(fails)} FAILED")
|
|
458
|
+
return len(fails)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
# ── detect + bootstrap ───────────────────────────────────────────────────────
|
|
462
|
+
# Facts only. Judgment (what a folder is FOR, which layout fits) is the
|
|
463
|
+
# structure-bootstrap skill's job; this never guesses a purpose.
|
|
464
|
+
|
|
465
|
+
WORKSPACE_FILES = ["pnpm-workspace.yaml", "turbo.json", "nx.json", "lerna.json", "go.work", "rush.json"]
|
|
466
|
+
PACKAGE_MANIFESTS = {"package.json", "pyproject.toml", "go.mod", "Cargo.toml"}
|
|
467
|
+
PACKAGE_PARENTS = {"apps", "packages", "services", "libs"}
|
|
468
|
+
KNOWN_ENVS = ["local", "dev", "development", "test", "qa", "uat", "stg", "stage", "staging",
|
|
469
|
+
"preprod", "prod", "production"]
|
|
470
|
+
WORKFLOW_ENV_RE = re.compile(r"^\s*environment:\s*(?:\n\s*name:\s*)?['\"]?([A-Za-z][\w-]*)", re.M)
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def all_files() -> list[str]:
|
|
474
|
+
"""Tracked + untracked-but-not-ignored: a brand-new repo may have nothing staged yet."""
|
|
475
|
+
out = git("ls-files", "--cached", "--others", "--exclude-standard", "-z")
|
|
476
|
+
return sorted({p for p in out.split("\0") if p})
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def read(rel: str) -> str:
|
|
480
|
+
try:
|
|
481
|
+
return (PROJECT_ROOT / rel).read_text(encoding="utf-8", errors="replace")
|
|
482
|
+
except OSError:
|
|
483
|
+
return ""
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
def sibling_repos() -> list[str]:
|
|
487
|
+
try:
|
|
488
|
+
return sorted(c.name for c in PROJECT_ROOT.parent.iterdir()
|
|
489
|
+
if c != PROJECT_ROOT and c.is_dir() and (c / ".git").exists())
|
|
490
|
+
except OSError:
|
|
491
|
+
return []
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
def detect_envs(files: list[str]) -> dict[str, list[str]]:
|
|
495
|
+
"""-> {env name: [evidence paths]} from .env.<x>, compose.<x>.yml, <env dir>/<x>/, workflow environments."""
|
|
496
|
+
found: dict[str, set[str]] = {}
|
|
497
|
+
for p in files:
|
|
498
|
+
parts = p.split("/")
|
|
499
|
+
name = parts[-1]
|
|
500
|
+
hits = []
|
|
501
|
+
m = re.match(r"\.env\.([a-z]+)", name) or re.match(r"(?:docker-)?compose[.-]([a-z]+)\.ya?ml$", name)
|
|
502
|
+
if m:
|
|
503
|
+
hits.append(m.group(1))
|
|
504
|
+
if len(parts) > 1 and "compose" in parts[-2] and name.rsplit(".", 1)[0] in KNOWN_ENVS:
|
|
505
|
+
hits.append(name.rsplit(".", 1)[0]) # infra/compose/dev.yml
|
|
506
|
+
for i in range(1, len(parts) - 1):
|
|
507
|
+
if parts[i - 1] in ENV_PARENT_DIRS | {"deploy"}:
|
|
508
|
+
hits.append(parts[i])
|
|
509
|
+
for h in hits:
|
|
510
|
+
if h in KNOWN_ENVS:
|
|
511
|
+
found.setdefault(h, set()).add(p)
|
|
512
|
+
if p.startswith(".github/workflows/") and p.endswith((".yml", ".yaml")):
|
|
513
|
+
for h in WORKFLOW_ENV_RE.findall(read(p)):
|
|
514
|
+
found.setdefault(h, set()).add(p)
|
|
515
|
+
return {k: sorted(v) for k, v in sorted(found.items(), key=lambda kv: (KNOWN_ENVS + [kv[0]]).index(kv[0]))}
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def detect() -> dict:
|
|
519
|
+
files = all_files()
|
|
520
|
+
workspace = [w for w in WORKSPACE_FILES if w in files]
|
|
521
|
+
if "package.json" in files and '"workspaces"' in read("package.json"):
|
|
522
|
+
workspace.append("package.json workspaces")
|
|
523
|
+
if "Cargo.toml" in files and "[workspace]" in read("Cargo.toml"):
|
|
524
|
+
workspace.append("Cargo.toml [workspace]")
|
|
525
|
+
packages = sorted({p.rsplit("/", 1)[0] for p in files
|
|
526
|
+
if "/" in p and p.rsplit("/", 1)[1] in PACKAGE_MANIFESTS and "node_modules/" not in p})
|
|
527
|
+
grouped = [p for p in packages if p.count("/") == 1 and p.split("/")[0] in PACKAGE_PARENTS]
|
|
528
|
+
siblings = sibling_repos()
|
|
529
|
+
submodules = ".gitmodules" in files
|
|
530
|
+
|
|
531
|
+
reasons = []
|
|
532
|
+
if workspace:
|
|
533
|
+
mode = "monorepo"
|
|
534
|
+
reasons.append(f"workspace config: {', '.join(workspace)}")
|
|
535
|
+
elif len(grouped) >= 2:
|
|
536
|
+
mode = "monorepo"
|
|
537
|
+
reasons.append(f"{len(grouped)} packages with their own manifest: {', '.join(grouped[:5])}")
|
|
538
|
+
elif siblings or submodules:
|
|
539
|
+
mode = "multi-repo"
|
|
540
|
+
else:
|
|
541
|
+
mode = "single"
|
|
542
|
+
reasons.append("no workspace config, package folders or sibling repos")
|
|
543
|
+
if siblings:
|
|
544
|
+
reasons.append(f"{len(siblings)} sibling git repo(s) in {PROJECT_ROOT.parent.name}/ -- candidates for [[repo]] pointers")
|
|
545
|
+
if submodules:
|
|
546
|
+
reasons.append(".gitmodules present -- submodules are other repos; point at them with [[repo]]")
|
|
547
|
+
|
|
548
|
+
top: dict[str, dict] = {}
|
|
549
|
+
for p in files:
|
|
550
|
+
if "/" in p:
|
|
551
|
+
d = top.setdefault(p.split("/", 1)[0], {"files": 0, "exts": {}})
|
|
552
|
+
d["files"] += 1
|
|
553
|
+
ext = Path(p).suffix or "(none)"
|
|
554
|
+
d["exts"][ext] = d["exts"].get(ext, 0) + 1
|
|
555
|
+
top_level = [{"path": k, "files": v["files"],
|
|
556
|
+
"exts": [e for e, _ in sorted(v["exts"].items(), key=lambda kv: -kv[1])[:4]]}
|
|
557
|
+
for k, v in sorted(top.items())]
|
|
558
|
+
root_files = [p for p in files if "/" not in p]
|
|
559
|
+
ignored = [p for p in git("ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z").split("\0")
|
|
560
|
+
if p and not p.endswith("/")]
|
|
561
|
+
env_files = {"templates": [p for p in files if env_kind(p.rsplit("/", 1)[-1]) == "template"],
|
|
562
|
+
"real": sorted(p for p in files + ignored if env_kind(p.rsplit("/", 1)[-1]) == "secret")}
|
|
563
|
+
near_empty = all(p.startswith(".") or p.upper().startswith(("README", "LICENSE", "CHANGELOG"))
|
|
564
|
+
for p in root_files) and not top_level
|
|
565
|
+
return {
|
|
566
|
+
"mode": mode, "reasons": reasons, "workspace": workspace, "packages": packages,
|
|
567
|
+
"sibling_repos": siblings, "submodules": submodules, "environments": detect_envs(files),
|
|
568
|
+
"top_level": top_level, "root_files": root_files, "env_files": env_files, "near_empty": near_empty,
|
|
569
|
+
"manifest": MANIFEST.is_file(), "layouts": layouts(),
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def layouts() -> list[str]:
|
|
574
|
+
return sorted(p.stem for p in LAYOUTS_DIR.glob("*.toml")) if LAYOUTS_DIR.is_dir() else []
|
|
575
|
+
|
|
576
|
+
|
|
577
|
+
def print_detect(d: dict) -> None:
|
|
578
|
+
print(f"Detected mode: {d['mode']}")
|
|
579
|
+
for r in d["reasons"]:
|
|
580
|
+
print(f" - {r}")
|
|
581
|
+
if d["environments"]:
|
|
582
|
+
print("Environments seen: " + ", ".join(f"{k} ({len(v)})" for k, v in d["environments"].items()))
|
|
583
|
+
if d["top_level"]:
|
|
584
|
+
print("Top-level folders: " + ", ".join(f"{t['path']}/ ({t['files']})" for t in d["top_level"]))
|
|
585
|
+
print(f"Root files: {', '.join(d['root_files']) or '(none)'}")
|
|
586
|
+
ef = d["env_files"]
|
|
587
|
+
if ef["templates"] or ef["real"]:
|
|
588
|
+
print(f"Env files: {len(ef['templates'])} template(s) {ef['templates']}, {len(ef['real'])} real {ef['real']}"
|
|
589
|
+
+ (" -- aim for one of each" if len(ef["templates"]) > 1 or len(ef["real"]) > 1 else ""))
|
|
590
|
+
if d["near_empty"]:
|
|
591
|
+
print("New repo: nothing here yet but README/LICENSE/dotfiles")
|
|
592
|
+
print(f"Layouts available: {', '.join(d['layouts']) or '(none installed)'}")
|
|
593
|
+
|
|
594
|
+
|
|
595
|
+
def print_options(d: dict) -> None:
|
|
596
|
+
print("No .claude/structure.toml -- structure checking is off.\n")
|
|
597
|
+
print_detect(d)
|
|
598
|
+
print("""
|
|
599
|
+
Modes:
|
|
600
|
+
single one project, flat layout
|
|
601
|
+
monorepo several apps/packages in one repo (apps/<name>/, packages/<name>/)
|
|
602
|
+
multi-repo this repo is one of several; each runs its own babel-fish, and
|
|
603
|
+
[[repo]] entries point at the others
|
|
604
|
+
|
|
605
|
+
To turn it on:
|
|
606
|
+
ask Claude to "set up the repo structure" (structure-bootstrap skill: reads the
|
|
607
|
+
tree, fills in purposes, asks what it can't infer)
|
|
608
|
+
python .claude/project-map/structure-check.py --bootstrap (dump the current tree)
|
|
609
|
+
python .claude/project-map/structure-check.py --bootstrap --layout NAME (start from a layout)""")
|
|
610
|
+
|
|
611
|
+
|
|
612
|
+
def fmt(v) -> str:
|
|
613
|
+
if isinstance(v, list):
|
|
614
|
+
items = [json.dumps(x) for x in v]
|
|
615
|
+
one = "[" + ", ".join(items) + "]"
|
|
616
|
+
return one if len(one) <= 80 else "[\n" + "".join(f" {x},\n" for x in items) + "]"
|
|
617
|
+
if isinstance(v, bool):
|
|
618
|
+
return str(v).lower()
|
|
619
|
+
return json.dumps(v) if isinstance(v, str) else str(v)
|
|
620
|
+
|
|
621
|
+
|
|
622
|
+
def to_toml(m: dict) -> str:
|
|
623
|
+
lines = ["# Repo structure manifest -- checked by .claude/project-map/structure-check.py (#22).",
|
|
624
|
+
"# This is the repo's own data: edit it by hand; babel-fish never overwrites it."]
|
|
625
|
+
for k in ("mode", "env_roots", "env_file", "root_files", "exceptions"):
|
|
626
|
+
if k in m:
|
|
627
|
+
lines.append(f"{k} = {fmt(m[k])}")
|
|
628
|
+
for table in ("folder", "environment", "repo"):
|
|
629
|
+
for t in m.get(table, []):
|
|
630
|
+
lines += ["", f"[[{table}]]"] + [f"{k} = {fmt(v)}" for k, v in t.items()]
|
|
631
|
+
return "\n".join(lines) + "\n"
|
|
632
|
+
|
|
633
|
+
|
|
634
|
+
def bootstrap(layout: str | None, mode: str | None) -> int:
|
|
635
|
+
if MANIFEST.exists():
|
|
636
|
+
print(f"FAIL {MANIFEST.relative_to(PROJECT_ROOT)} already exists -- edit it by hand; bootstrap never overwrites it")
|
|
637
|
+
return 2
|
|
638
|
+
d = detect()
|
|
639
|
+
today = date.today().isoformat()
|
|
640
|
+
if layout:
|
|
641
|
+
src = LAYOUTS_DIR / f"{layout}.toml"
|
|
642
|
+
if not src.is_file():
|
|
643
|
+
print(f"FAIL no layout {layout!r} -- available: {', '.join(d['layouts']) or '(none installed)'}")
|
|
644
|
+
return 2
|
|
645
|
+
if tomllib is None:
|
|
646
|
+
warn_old_python(f"the {layout} layout")
|
|
647
|
+
return 0
|
|
648
|
+
m = load_manifest(src)
|
|
649
|
+
else:
|
|
650
|
+
m = {"mode": d["mode"], "root_files": d["root_files"], "folder": []}
|
|
651
|
+
if mode:
|
|
652
|
+
m["mode"] = mode
|
|
653
|
+
|
|
654
|
+
files, on_disk = tracked(), all_files()
|
|
655
|
+
for f in m.get("folder", []): # a layout's folders: active if they exist, planned if not
|
|
656
|
+
exists = any(p.startswith(f["path"] + "/") for p in on_disk)
|
|
657
|
+
f["status"] = "active" if exists else "planned"
|
|
658
|
+
f.setdefault("added", today)
|
|
659
|
+
# The existing structure wins: a top-level folder nothing covers becomes its own entry. Never a
|
|
660
|
+
# `dir/**` exception -- a glob would wave through every FUTURE file in it too.
|
|
661
|
+
folders = sorted(m.get("folder", []), key=lambda f: -f["path"].count("/") - len(f["path"]))
|
|
662
|
+
uncovered = sorted({p.split("/", 1)[0] for p in on_disk if "/" in p and owner(p, folders) is None})
|
|
663
|
+
for top in uncovered:
|
|
664
|
+
purpose = "TODO: what belongs here" + (f" (not in the {layout} layout)" if layout else "")
|
|
665
|
+
m.setdefault("folder", []).append({"path": top, "purpose": purpose, "status": "active", "added": today})
|
|
666
|
+
|
|
667
|
+
templates = [p for p in files if env_kind(p.rsplit("/", 1)[-1]) == "template"]
|
|
668
|
+
if len(templates) == 1 and templates[0].rsplit("/", 1)[-1] == ".env.example":
|
|
669
|
+
m["env_file"] = templates[0] # already unified: the existing location wins over the layout's
|
|
670
|
+
|
|
671
|
+
# Seed the baseline: whatever fails today is allowed until fixed; only NEW violations block.
|
|
672
|
+
env = check_env_files(m, files)
|
|
673
|
+
findings = check_folders(m, files, set()) + check_environments(m, files) + env
|
|
674
|
+
secrets = [f for f in env if f.level == "FAIL" and not f.exemptable]
|
|
675
|
+
never = {f.path for f in secrets} # an exact-path exception would also silence the secrets FAIL
|
|
676
|
+
exc = set()
|
|
677
|
+
for f in findings:
|
|
678
|
+
if f.level == "FAIL" and f.exemptable and f.path not in never:
|
|
679
|
+
exc.add(f.path) # exact paths only: the baseline must not allow anything new
|
|
680
|
+
if exc:
|
|
681
|
+
m["exceptions"] = sorted(exc)
|
|
682
|
+
|
|
683
|
+
text = to_toml(m)
|
|
684
|
+
if d["environments"] and not m.get("environment"):
|
|
685
|
+
text += "\n# Environments seen in this tree -- declare them to turn on the dev -> stg -> prod rules:\n"
|
|
686
|
+
for name in d["environments"]:
|
|
687
|
+
target = "local" if name in ("local", "dev", "development") else "cloud"
|
|
688
|
+
text += f'# [[environment]]\n# name = "{name}"\n# target = "{target}"\n'
|
|
689
|
+
if m["mode"] == "multi-repo" and d["sibling_repos"] and not m.get("repo"):
|
|
690
|
+
text += "\n# Sibling repos -- uncomment the ones this repo works with:\n"
|
|
691
|
+
for name in d["sibling_repos"]:
|
|
692
|
+
text += f'# [[repo]]\n# name = "{name}"\n# path = "../{name}"\n# purpose = "TODO"\n'
|
|
693
|
+
MANIFEST.parent.mkdir(parents=True, exist_ok=True)
|
|
694
|
+
MANIFEST.write_text(text)
|
|
695
|
+
|
|
696
|
+
print(f"Wrote {MANIFEST.relative_to(PROJECT_ROOT)} (mode: {m['mode']}"
|
|
697
|
+
+ (f", layout: {layout}" if layout else "") + ")")
|
|
698
|
+
print(f" {len(m.get('folder', []))} folders, {len(exc)} exception(s) seeded from today's tree")
|
|
699
|
+
for f in secrets:
|
|
700
|
+
print(f" FAIL {f.path} -- {f.msg.split(' -- ', 1)[0]}; the check fails until it's fixed "
|
|
701
|
+
"(a committed fixture with no real secrets: add its exact path to exceptions by hand)")
|
|
702
|
+
if len(templates) > 1:
|
|
703
|
+
print(f" {len(templates)} env templates -- all but {env_file_path(m)} are in exceptions as the merge backlog")
|
|
704
|
+
print("Next: fill in each TODO purpose, tighten holds, run structure-check.py, commit the manifest.")
|
|
705
|
+
return 0
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
# ── cli ──────────────────────────────────────────────────────────────────────
|
|
709
|
+
|
|
710
|
+
def warn_old_python(what: str = ".claude/structure.toml") -> None:
|
|
711
|
+
v = ".".join(map(str, sys.version_info[:3]))
|
|
712
|
+
print(f"WARN structure-check skipped: it reads {what} with Python's built-in\n"
|
|
713
|
+
f" TOML parser (tomllib), which needs Python 3.11+. This is Python {v}.\n"
|
|
714
|
+
f" Nothing was checked. Upgrade to Python 3.11 or newer to turn the check on;\n"
|
|
715
|
+
f" CI running 3.11+ still enforces it.")
|
|
716
|
+
|
|
717
|
+
|
|
718
|
+
def main() -> None:
|
|
719
|
+
ap = argparse.ArgumentParser(description="Check tracked files against .claude/structure.toml (#22).")
|
|
720
|
+
ap.add_argument("--detect", action="store_true", help="print repo facts (mode, environments, folders) and exit")
|
|
721
|
+
ap.add_argument("--json", action="store_true", help="with --detect: machine-readable output")
|
|
722
|
+
ap.add_argument("--bootstrap", action="store_true", help="write a starting manifest; never overwrites")
|
|
723
|
+
ap.add_argument("--layout", metavar="NAME", help="with --bootstrap: start from .claude/templates/structure/NAME.toml")
|
|
724
|
+
ap.add_argument("--mode", choices=MODES, help="with --bootstrap: override the detected mode")
|
|
725
|
+
new = ap.add_mutually_exclusive_group()
|
|
726
|
+
new.add_argument("--staged", action="store_true",
|
|
727
|
+
help="treat staged additions as new files (pre-commit)")
|
|
728
|
+
new.add_argument("--since", metavar="REF",
|
|
729
|
+
help="treat files added since REF as new (CI, e.g. --since origin/main)")
|
|
730
|
+
ap.add_argument("--warn-only", action="store_true", help="report, but always exit 0")
|
|
731
|
+
ap.add_argument("--project-root", type=Path, default=None)
|
|
732
|
+
args = ap.parse_args()
|
|
733
|
+
if args.project_root:
|
|
734
|
+
configure_paths(args.project_root.resolve())
|
|
735
|
+
|
|
736
|
+
if not is_git_repo():
|
|
737
|
+
print(f"FAIL {PROJECT_ROOT} is not a git repository -- structure-check reads the git index (git init first)")
|
|
738
|
+
sys.exit(2)
|
|
739
|
+
if args.detect:
|
|
740
|
+
d = detect()
|
|
741
|
+
if args.json:
|
|
742
|
+
print(json.dumps(d, indent=2))
|
|
743
|
+
else:
|
|
744
|
+
print_detect(d)
|
|
745
|
+
sys.exit(0)
|
|
746
|
+
if args.bootstrap:
|
|
747
|
+
sys.exit(bootstrap(args.layout, args.mode))
|
|
748
|
+
if not MANIFEST.is_file():
|
|
749
|
+
print_options(detect())
|
|
750
|
+
sys.exit(0)
|
|
751
|
+
if tomllib is None:
|
|
752
|
+
warn_old_python()
|
|
753
|
+
sys.exit(0)
|
|
754
|
+
try:
|
|
755
|
+
m = load_manifest(MANIFEST)
|
|
756
|
+
except tomllib.TOMLDecodeError as e:
|
|
757
|
+
print(f"FAIL .claude/structure.toml is not valid TOML: {e}")
|
|
758
|
+
sys.exit(2)
|
|
759
|
+
errs = validate(m)
|
|
760
|
+
for e in errs:
|
|
761
|
+
print(f"FAIL .claude/structure.toml: {e}")
|
|
762
|
+
if errs:
|
|
763
|
+
sys.exit(2)
|
|
764
|
+
try:
|
|
765
|
+
fails = run(m, args.staged, args.since)
|
|
766
|
+
except RuntimeError as e:
|
|
767
|
+
print(f"FAIL {e}")
|
|
768
|
+
sys.exit(2)
|
|
769
|
+
sys.exit(1 if fails and not args.warn_only else 0)
|
|
770
|
+
|
|
771
|
+
|
|
772
|
+
if __name__ == "__main__":
|
|
773
|
+
main()
|