@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.
@@ -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()