@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,864 @@
1
+ #!/usr/bin/env python3
2
+ """Regression tests for structure-check.py — run: python3 .claude/project-map/test_structure_check.py
3
+
4
+ Stdlib assert + __main__, same shape as the sibling suites. Every rule is proven
5
+ to DETECT a seeded violation as well as to pass a clean tree.
6
+
7
+ Every fixture is a temp git repo passed via --project-root, so nothing here reads
8
+ or writes the real repo (#18).
9
+ """
10
+ from __future__ import annotations
11
+
12
+ import inspect
13
+ import json
14
+ import re
15
+ import shutil
16
+ import subprocess
17
+ import sys
18
+ import tempfile
19
+ from pathlib import Path
20
+
21
+ HERE = Path(__file__).parent
22
+ SCRIPT = HERE / "structure-check.py"
23
+ REPO = HERE.parent.parent
24
+
25
+ GIT = ["git", "-c", "user.name=t", "-c", "user.email=t@t", "-c", "commit.gpgsign=false"]
26
+
27
+ BASE_MANIFEST = '''
28
+ mode = "single"
29
+ root_files = ["README.md"]
30
+
31
+ [[folder]]
32
+ path = "src"
33
+ purpose = "Application source"
34
+ holds = ["**/*.py"]
35
+ status = "active"
36
+ '''
37
+
38
+
39
+ def make_repo(root: Path, files: dict[str, str], manifest: str | None = BASE_MANIFEST,
40
+ commit: bool = True) -> Path:
41
+ subprocess.run(["git", "init", "-q", "-b", "main", str(root)], check=True)
42
+ for rel, text in files.items():
43
+ (root / rel).parent.mkdir(parents=True, exist_ok=True)
44
+ (root / rel).write_text(text)
45
+ if manifest is not None:
46
+ (root / ".claude").mkdir(exist_ok=True)
47
+ (root / ".claude/structure.toml").write_text(manifest)
48
+ subprocess.run(["git", "add", "-A"], cwd=root, check=True)
49
+ if commit:
50
+ subprocess.run(GIT + ["commit", "-qm", "init"], cwd=root, check=True)
51
+ return root
52
+
53
+
54
+ def check(root: Path, *flags: str) -> subprocess.CompletedProcess:
55
+ return subprocess.run([sys.executable, str(SCRIPT), "--project-root", str(root), *flags],
56
+ capture_output=True, text=True)
57
+
58
+
59
+ def with_manifest(extra: str) -> str:
60
+ return BASE_MANIFEST + extra
61
+
62
+
63
+ # ── manifest ─────────────────────────────────────────────────────────────────
64
+
65
+ def test_no_manifest_is_off_not_a_failure(tmp):
66
+ r = check(make_repo(tmp, {"README.md": "x"}, manifest=None))
67
+ assert r.returncode == 0, r.stdout
68
+ assert "structure.toml" in r.stdout, r.stdout
69
+
70
+
71
+ def test_valid_manifest_loads(tmp):
72
+ r = check(make_repo(tmp, {"README.md": "x", "src/a.py": "x"}))
73
+ assert r.returncode == 0, r.stdout + r.stderr
74
+
75
+
76
+ def test_invalid_toml_exits_2(tmp):
77
+ r = check(make_repo(tmp, {"README.md": "x"}, manifest="mode = \n"))
78
+ assert r.returncode == 2 and "not valid TOML" in r.stdout, r.stdout
79
+
80
+
81
+ def test_bad_status_exits_2(tmp):
82
+ m = BASE_MANIFEST.replace('status = "active"', 'status = "retired"')
83
+ r = check(make_repo(tmp, {"README.md": "x"}, manifest=m))
84
+ assert r.returncode == 2 and "retired" in r.stdout, r.stdout
85
+
86
+
87
+ def test_bad_mode_exits_2(tmp):
88
+ r = check(make_repo(tmp, {"README.md": "x"}, manifest='mode = "polyrepo"\n'))
89
+ assert r.returncode == 2 and "polyrepo" in r.stdout, r.stdout
90
+
91
+
92
+ def test_duplicate_folder_exits_2(tmp):
93
+ r = check(make_repo(tmp, {"README.md": "x"}, manifest=with_manifest('[[folder]]\npath = "src/"\n')))
94
+ assert r.returncode == 2 and "listed twice" in r.stdout, r.stdout
95
+
96
+
97
+ def test_env_pointing_at_undeclared_env_exits_2(tmp):
98
+ m = with_manifest('[[environment]]\nname = "dev"\ntarget = "local"\npromotes_to = "qa"\n')
99
+ r = check(make_repo(tmp, {"README.md": "x"}, manifest=m))
100
+ assert r.returncode == 2 and "'qa' is not a declared environment" in r.stdout, r.stdout
101
+
102
+
103
+ def test_promotion_cycle_exits_2(tmp):
104
+ m = with_manifest('[[environment]]\nname = "a"\ntarget = "cloud"\npromotes_to = "b"\n'
105
+ '[[environment]]\nname = "b"\ntarget = "cloud"\npromotes_to = "a"\n')
106
+ r = check(make_repo(tmp, {"README.md": "x"}, manifest=m))
107
+ assert r.returncode == 2 and "loops" in r.stdout, r.stdout
108
+
109
+
110
+ def test_bad_env_target_exits_2(tmp):
111
+ m = with_manifest('[[environment]]\nname = "dev"\ntarget = "laptop"\n')
112
+ r = check(make_repo(tmp, {"README.md": "x"}, manifest=m))
113
+ assert r.returncode == 2 and "laptop" in r.stdout, r.stdout
114
+
115
+
116
+ def test_repo_pointer_needs_path_or_url(tmp):
117
+ r = check(make_repo(tmp, {"README.md": "x"}, manifest=with_manifest('[[repo]]\nname = "api"\n')))
118
+ assert r.returncode == 2 and "needs a path or a url" in r.stdout, r.stdout
119
+
120
+
121
+ def test_not_a_git_repo_exits_2(tmp):
122
+ (tmp / ".claude").mkdir()
123
+ (tmp / ".claude/structure.toml").write_text(BASE_MANIFEST)
124
+ r = check(tmp)
125
+ assert r.returncode == 2 and "not a git repository" in r.stdout, r.stdout
126
+
127
+
128
+ # ── folders ──────────────────────────────────────────────────────────────────
129
+
130
+ def test_clean_tree_passes(tmp):
131
+ r = check(make_repo(tmp, {"README.md": "x", "src/a.py": "x", "src/pkg/b.py": "x"}))
132
+ assert r.returncode == 0 and "OK" in r.stdout, r.stdout
133
+
134
+
135
+ def test_path_under_no_folder_fails(tmp):
136
+ r = check(make_repo(tmp, {"README.md": "x", "tools/x.py": "x"}))
137
+ assert r.returncode == 1 and "tools/x.py" in r.stdout and "under no manifest folder" in r.stdout, r.stdout
138
+
139
+
140
+ def test_root_file_not_listed_fails(tmp):
141
+ r = check(make_repo(tmp, {"README.md": "x", "notes.txt": "x"}))
142
+ assert r.returncode == 1 and "notes.txt" in r.stdout and "root_files" in r.stdout, r.stdout
143
+
144
+
145
+ def test_root_files_accept_globs(tmp):
146
+ m = BASE_MANIFEST.replace('root_files = ["README.md"]', 'root_files = ["README.md", "*.lock"]')
147
+ r = check(make_repo(tmp, {"README.md": "x", "poetry.lock": "x"}, manifest=m))
148
+ assert r.returncode == 0, r.stdout
149
+
150
+
151
+ def test_file_outside_holds_fails(tmp):
152
+ r = check(make_repo(tmp, {"README.md": "x", "src/a.py": "x", "src/compose.yml": "x"}))
153
+ assert r.returncode == 1 and "src/compose.yml" in r.stdout and "holds" in r.stdout, r.stdout
154
+
155
+
156
+ def test_deepest_folder_wins(tmp):
157
+ m = with_manifest('[[folder]]\npath = "src/assets"\nholds = ["*.png"]\n')
158
+ r = check(make_repo(tmp, {"README.md": "x", "src/assets/logo.png": "x"}, manifest=m))
159
+ assert r.returncode == 0, r.stdout # src's holds (*.py) must not apply to src/assets
160
+ r = check(make_repo(tmp / "b", {"README.md": "x", "src/assets/x.py": "x"}, manifest=m))
161
+ assert r.returncode == 1 and "src/assets/x.py" in r.stdout, r.stdout
162
+
163
+
164
+ DEPRECATED = with_manifest('[[folder]]\npath = "legacy"\nstatus = "deprecated"\n')
165
+
166
+
167
+ def test_existing_file_in_deprecated_folder_passes(tmp):
168
+ r = check(make_repo(tmp, {"README.md": "x", "legacy/old.py": "x"}, manifest=DEPRECATED), "--staged")
169
+ assert r.returncode == 0, r.stdout
170
+
171
+
172
+ def test_new_staged_file_in_deprecated_folder_fails(tmp):
173
+ make_repo(tmp, {"README.md": "x", "legacy/old.py": "x"}, manifest=DEPRECATED)
174
+ (tmp / "legacy/new.py").write_text("x")
175
+ subprocess.run(["git", "add", "-A"], cwd=tmp, check=True)
176
+ r = check(tmp, "--staged")
177
+ assert r.returncode == 1 and "legacy/new.py" in r.stdout and "deprecated" in r.stdout, r.stdout
178
+ assert "legacy/old.py" not in r.stdout, r.stdout
179
+
180
+
181
+ def test_since_ref_catches_new_file_in_deprecated_folder(tmp):
182
+ make_repo(tmp, {"README.md": "x", "legacy/old.py": "x"}, manifest=DEPRECATED)
183
+ subprocess.run(["git", "checkout", "-qb", "feature"], cwd=tmp, check=True)
184
+ (tmp / "legacy/new.py").write_text("x")
185
+ subprocess.run(["git", "add", "-A"], cwd=tmp, check=True)
186
+ subprocess.run(GIT + ["commit", "-qm", "add"], cwd=tmp, check=True)
187
+ r = check(tmp, "--since", "main")
188
+ assert r.returncode == 1 and "legacy/new.py" in r.stdout, r.stdout
189
+ assert check(tmp).returncode == 0 # without a diff base nothing is "new"
190
+
191
+
192
+ def test_empty_deprecated_folder_warns(tmp):
193
+ r = check(make_repo(tmp, {"README.md": "x"}, manifest=DEPRECATED))
194
+ assert r.returncode == 0 and "WARN legacy/" in r.stdout and "remove" in r.stdout, r.stdout
195
+
196
+
197
+ def test_planned_folder_with_files_warns(tmp):
198
+ m = with_manifest('[[folder]]\npath = "docs"\nstatus = "planned"\n')
199
+ r = check(make_repo(tmp, {"README.md": "x", "docs/a.md": "x"}, manifest=m))
200
+ assert r.returncode == 0 and "WARN docs/" in r.stdout and "active" in r.stdout, r.stdout
201
+
202
+
203
+ def test_exceptions_allow_known_violations(tmp):
204
+ m = BASE_MANIFEST.replace('root_files', 'exceptions = ["tools/**"]\nroot_files')
205
+ r = check(make_repo(tmp, {"README.md": "x", "tools/x.py": "x"}, manifest=m))
206
+ assert r.returncode == 0 and "1 allowed by exceptions" in r.stdout, r.stdout
207
+
208
+
209
+ def test_stale_exception_warns(tmp):
210
+ m = BASE_MANIFEST.replace('root_files', 'exceptions = ["gone/**"]\nroot_files')
211
+ r = check(make_repo(tmp, {"README.md": "x"}, manifest=m))
212
+ assert r.returncode == 0 and "WARN gone/**" in r.stdout and "no longer needed" in r.stdout, r.stdout
213
+
214
+
215
+ def test_warn_only_exits_zero(tmp):
216
+ r = check(make_repo(tmp, {"README.md": "x", "tools/x.py": "x"}), "--warn-only")
217
+ assert r.returncode == 0 and "FAIL" in r.stdout, r.stdout
218
+
219
+
220
+ def test_repo_pointer_without_checkout_warns(tmp):
221
+ m = with_manifest('[[repo]]\nname = "api"\npath = "../api"\n')
222
+ r = check(make_repo(tmp / "web", {"README.md": "x"}, manifest=m))
223
+ assert r.returncode == 0 and "WARN ../api" in r.stdout, r.stdout
224
+ subprocess.run(["git", "init", "-q", str(tmp / "api")], check=True)
225
+ assert "WARN ../api" not in check(tmp / "web").stdout
226
+
227
+
228
+ # ── environments ─────────────────────────────────────────────────────────────
229
+
230
+ ENV_MANIFEST = '''
231
+ mode = "monorepo"
232
+ root_files = ["README.md"]
233
+
234
+ [[folder]]
235
+ path = "infra"
236
+
237
+ [[folder]]
238
+ path = "apps"
239
+
240
+ [[folder]]
241
+ path = "migrations"
242
+
243
+ [[folder]]
244
+ path = ".github"
245
+
246
+ [[environment]]
247
+ name = "dev"
248
+ target = "local"
249
+ promotes_to = "stg"
250
+
251
+ [[environment]]
252
+ name = "stg"
253
+ target = "cloud"
254
+ promotes_to = "prod"
255
+ mirrors = "prod"
256
+
257
+ [[environment]]
258
+ name = "prod"
259
+ target = "cloud"
260
+ '''
261
+
262
+ ENV_TREE = {
263
+ "README.md": "x",
264
+ "infra/env/.env.example": "A=\n", # the one template, for every environment
265
+ "infra/compose/base.yml": "services: {}\n",
266
+ "infra/deploy/base/main.tf": "module {}\n",
267
+ "infra/deploy/stg/main.tf": "env = stg\n",
268
+ "infra/deploy/prod/main.tf": "env = prod\n",
269
+ "apps/api/main.py": "x",
270
+ "migrations/001_init.sql": "x",
271
+ }
272
+
273
+
274
+ def env_add(tmp: Path, files: dict[str, str]) -> subprocess.CompletedProcess:
275
+ tree = {**ENV_TREE, **files}
276
+ return check(make_repo(tmp, tree, manifest=ENV_MANIFEST))
277
+
278
+
279
+ def test_clean_environment_tree_passes(tmp):
280
+ r = check(make_repo(tmp, ENV_TREE, manifest=ENV_MANIFEST))
281
+ assert r.returncode == 0, r.stdout
282
+
283
+
284
+ def test_undeclared_environment_folder_fails(tmp):
285
+ r = env_add(tmp, {"infra/env/qa/.env.example": "A=qa\n"})
286
+ assert r.returncode == 1 and "infra/env/qa/" in r.stdout and "'qa' is not declared" in r.stdout, r.stdout
287
+
288
+
289
+ def test_stg_must_mirror_prod(tmp):
290
+ r = env_add(tmp, {"infra/deploy/prod/redis.tf": "redis\n"})
291
+ assert r.returncode == 1 and "infra/deploy/stg/redis.tf" in r.stdout and "mirrors prod" in r.stdout, r.stdout
292
+
293
+
294
+ def test_dev_is_exempt_from_parity(tmp):
295
+ r = env_add(tmp, {"infra/env/dev/seed.sql": "dev seed\n"})
296
+ assert r.returncode == 0, r.stdout
297
+
298
+
299
+ def test_local_only_file_in_cloud_env_fails(tmp):
300
+ r = env_add(tmp, {"infra/env/prod/seed.sql": "p\n", "infra/env/stg/seed.sql": "s\n"})
301
+ assert r.returncode == 1 and "infra/env/prod/seed.sql" in r.stdout and "local-only" in r.stdout, r.stdout
302
+
303
+
304
+ def test_deploy_file_in_local_env_fails(tmp):
305
+ r = env_add(tmp, {"infra/deploy/dev/main.tf": "dev\n"})
306
+ assert r.returncode == 1 and "infra/deploy/dev/main.tf" in r.stdout and "deploy config" in r.stdout, r.stdout
307
+
308
+
309
+ def test_identical_copy_of_shared_file_fails(tmp):
310
+ r = env_add(tmp, {"infra/env/dev/base.yml": "services: {}\n"})
311
+ assert r.returncode == 1 and "identical copy of infra/compose/base.yml" in r.stdout, r.stdout
312
+
313
+
314
+ def test_empty_files_are_not_copies(tmp):
315
+ r = env_add(tmp, {"infra/env/dev/.gitkeep": "", "apps/api/.gitkeep": ""})
316
+ assert r.returncode == 0, r.stdout
317
+
318
+
319
+ def test_per_environment_migrations_fail(tmp):
320
+ r = env_add(tmp, {"migrations/prod/002.sql": "p\n"})
321
+ assert r.returncode == 1 and "migrations/prod/002.sql" in r.stdout and "per-environment migration" in r.stdout
322
+ r = env_add(tmp / "b", {"infra/env/dev/migrations/001.sql": "d\n"})
323
+ assert r.returncode == 1 and "per-environment migration" in r.stdout, r.stdout
324
+
325
+
326
+ def test_per_app_env_folder_outside_env_roots_fails(tmp):
327
+ r = env_add(tmp, {"apps/api/env/prod/.env.example": "A=1\n"})
328
+ assert r.returncode == 1 and "apps/api/env/prod/" in r.stdout and "outside env_roots" in r.stdout, r.stdout
329
+
330
+
331
+ def test_custom_env_roots(tmp):
332
+ m = ENV_MANIFEST.replace('root_files', 'env_roots = ["k8s/overlays"]\nroot_files') + '[[folder]]\npath = "k8s"\n'
333
+ tree = {"README.md": "x", "k8s/overlays/stg/kustomization.yaml": "a", "k8s/overlays/prod/kustomization.yaml": "b"}
334
+ assert check(make_repo(tmp, tree, manifest=m)).returncode == 0
335
+ tree["k8s/overlays/prod/hpa.yaml"] = "c"
336
+ r = check(make_repo(tmp / "b", tree, manifest=m))
337
+ assert r.returncode == 1 and "k8s/overlays/stg/hpa.yaml" in r.stdout, r.stdout
338
+
339
+
340
+ def test_per_environment_workflow_copies_warn(tmp):
341
+ r = env_add(tmp, {".github/workflows/deploy-stg.yml": "a", ".github/workflows/deploy-prod.yml": "b"})
342
+ assert r.returncode == 0 and "deploy-{env}.yml" in r.stdout and "WARN" in r.stdout, r.stdout
343
+
344
+
345
+ def test_no_environments_declared_skips_env_rules(tmp):
346
+ m = ENV_MANIFEST.split("[[environment]]")[0].replace('root_files', 'env_file = "infra/env/.env.example"\nroot_files')
347
+ tree = {**ENV_TREE, "infra/env/qa/x": "1", "infra/deploy/prod/redis.tf": "r"}
348
+ assert check(make_repo(tmp, tree, manifest=m)).returncode == 0
349
+
350
+
351
+ # ── env files: one template, one real .env ───────────────────────────────────
352
+
353
+ SINGLE_ENV = BASE_MANIFEST.replace('["**/*.py"]', '["**"]') + '\n[[folder]]\npath = "apps"\n'
354
+
355
+
356
+ def test_one_env_template_passes_a_second_anywhere_fails(tmp):
357
+ assert check(make_repo(tmp / "a", {"README.md": "x", ".env.example": "A="},
358
+ manifest=SINGLE_ENV.replace('["README.md"]', '["README.md", ".env.example"]'))).returncode == 0
359
+ r = check(make_repo(tmp / "b", {"README.md": "x", ".env.example": "A=", "apps/api/.env.example": "B="},
360
+ manifest=SINGLE_ENV.replace('["README.md"]', '["README.md", ".env.example"]')))
361
+ assert r.returncode == 1 and "FAIL apps/api/.env.example -- scattered env template" in r.stdout, r.stdout
362
+ assert "merge its keys into .env.example" in r.stdout, r.stdout
363
+
364
+
365
+ def test_misnamed_template_beside_env_file_must_be_renamed(tmp):
366
+ m = SINGLE_ENV.replace('["README.md"]', '["README.md", ".env.*"]')
367
+ r = check(make_repo(tmp, {"README.md": "x", ".env.sample": "A="}, manifest=m))
368
+ assert r.returncode == 1 and "FAIL .env.sample -- env template named differently" in r.stdout, r.stdout
369
+
370
+
371
+ def test_env_file_setting_moves_the_one_template(tmp):
372
+ m = SINGLE_ENV.replace('root_files', 'env_file = "src/.env.example"\nroot_files')
373
+ assert check(make_repo(tmp / "a", {"README.md": "x", "src/.env.example": "A="}, manifest=m)).returncode == 0
374
+ bad = m.replace('src/.env.example"\n', 'src/env.template"\n', 1)
375
+ r = check(make_repo(tmp / "b", {"README.md": "x"}, manifest=bad))
376
+ assert r.returncode == 2 and "must be a path to a file named .env.example" in r.stdout, r.stdout
377
+
378
+
379
+ def test_with_environments_the_template_defaults_to_the_env_root(tmp):
380
+ r = check(make_repo(tmp, {**ENV_TREE, ".env.example": "A="}, manifest=ENV_MANIFEST.replace(
381
+ 'root_files = ["README.md"]', 'root_files = ["README.md", ".env.example"]')))
382
+ assert r.returncode == 1 and "FAIL .env.example -- scattered env template" in r.stdout, r.stdout
383
+ assert "merge its keys into infra/env/.env.example" in r.stdout, r.stdout
384
+
385
+
386
+ def test_real_env_that_git_would_commit_fails_before_it_is_staged(tmp):
387
+ root = make_repo(tmp, {"README.md": "x", ".env.example": "A="},
388
+ manifest=SINGLE_ENV.replace('["README.md"]', '["README.md", ".env.example", ".gitignore"]'))
389
+ (root / ".env").write_text("A=real") # untracked, NOT ignored
390
+ r = check(root)
391
+ assert r.returncode == 1 and "FAIL .env -- real env file is not gitignored" in r.stdout, r.stdout
392
+ (root / ".gitignore").write_text(".env\n.env.*\n!.env.example\n")
393
+ subprocess.run(["git", "add", ".gitignore"], cwd=root, check=True)
394
+ r = check(root)
395
+ assert r.returncode == 0 and ".env " not in r.stdout.replace(".env.example", ""), r.stdout
396
+
397
+
398
+ def test_scattered_local_env_files_warn(tmp):
399
+ root = make_repo(tmp, {"README.md": "x", ".env.example": "A=", ".gitignore": ".env\n.env.*\n!.env.example\n"},
400
+ manifest=SINGLE_ENV.replace('["README.md"]', '["README.md", ".env.example", ".gitignore"]'))
401
+ (root / ".env").write_text("A=dev") # the one real file: fine
402
+ (root / "apps/api").mkdir(parents=True)
403
+ (root / "apps/api/.env.local").write_text("A=other") # scattered, ignored
404
+ r = check(root)
405
+ assert r.returncode == 0, r.stdout
406
+ assert "WARN apps/api/.env.local -- local env file outside .env" in r.stdout, r.stdout
407
+ assert "WARN .env " not in r.stdout, r.stdout
408
+
409
+
410
+ def test_bootstrap_keeps_an_existing_single_template_where_it_is(tmp):
411
+ root = make_repo(tmp / "r", {"README.md": "x", "src/a.py": "x", ".env.example": "A="}, manifest=None)
412
+ assert check(root, "--bootstrap", "--layout", "single").returncode == 0
413
+ text = (root / ".claude/structure.toml").read_text()
414
+ import tomllib
415
+ m = tomllib.loads(text)
416
+ assert m["env_file"] == ".env.example" and ".env.example" not in m.get("exceptions", []), text
417
+ subprocess.run(["git", "add", "-A"], cwd=root, check=True)
418
+ r = check(root)
419
+ assert "scattered env template" not in r.stdout, r.stdout
420
+
421
+
422
+ def test_bootstrap_puts_extra_templates_in_the_merge_backlog(tmp):
423
+ tree = {"README.md": "x", "src/a.py": "x", ".env.example": "A=", "src/.env.sample": "B=", "tools/.env.example": "C="}
424
+ root = make_repo(tmp / "r", tree, manifest=None)
425
+ r = check(root, "--bootstrap", "--layout", "single")
426
+ assert r.returncode == 0 and "3 env templates" in r.stdout, r.stdout
427
+ text = (root / ".claude/structure.toml").read_text()
428
+ assert '"src/.env.sample"' in text and '"tools/.env.example"' in text, text
429
+ subprocess.run(["git", "add", "-A"], cwd=root, check=True)
430
+ assert check(root).returncode == 0
431
+ (root / "src/.env.example").write_text("D=") # a NEW scattered template still fails
432
+ subprocess.run(["git", "add", "-A"], cwd=root, check=True)
433
+ r = check(root)
434
+ assert r.returncode == 1 and "FAIL src/.env.example" in r.stdout, r.stdout
435
+
436
+
437
+ def test_detect_lists_env_files(tmp):
438
+ root = make_repo(tmp / "r", {".gitignore": ".env\n", ".env.example": "A=", "apps/x/.env.sample": "B="}, manifest=None)
439
+ (root / ".env").write_text("A=1")
440
+ d = detect(root)
441
+ assert d["env_files"] == {"templates": [".env.example", "apps/x/.env.sample"], "real": [".env"]}, d["env_files"]
442
+
443
+
444
+ # ── secrets ──────────────────────────────────────────────────────────────────
445
+
446
+ def test_tracked_env_file_fails_and_no_glob_can_except_it(tmp):
447
+ m = BASE_MANIFEST.replace('root_files', 'exceptions = ["src/*", "**/.env"]\nroot_files').replace('["**/*.py"]', '["**"]')
448
+ r = check(make_repo(tmp, {"README.md": "x", "src/.env": "SECRET=1"}, manifest=m))
449
+ assert r.returncode == 1 and "secrets file is tracked" in r.stdout, r.stdout
450
+
451
+
452
+ def test_secret_fixture_can_be_excepted_by_exact_path_only(tmp):
453
+ """Vendored test configs (.env.testing, .env.ci) are committed on purpose: the escape hatch
454
+ is an exact path, so a NEW .env next to them still fails."""
455
+ m = BASE_MANIFEST.replace('root_files', 'exceptions = ["src/.env.testing"]\nroot_files').replace('["**/*.py"]', '["**"]')
456
+ root = make_repo(tmp, {"README.md": "x", "src/.env.testing": "APP_KEY=test"}, manifest=m)
457
+ r = check(root)
458
+ assert r.returncode == 0 and "1 allowed by exceptions" in r.stdout, r.stdout
459
+ (root / "src/.env").write_text("SECRET=1")
460
+ subprocess.run(["git", "add", "-A"], cwd=root, check=True)
461
+ r = check(root)
462
+ assert r.returncode == 1 and "FAIL src/.env " in r.stdout and "src/.env.testing" not in r.stdout, r.stdout
463
+
464
+
465
+ def test_secret_variants_fail_examples_pass(tmp):
466
+ m = BASE_MANIFEST.replace('["**/*.py"]', '["**"]')
467
+ bad = {"src/.env.production": "x", "src/prod.env": "x"}
468
+ templates = {"src/.env.example": "x", "src/.env.prod.example": "x", "src/example.env": "x", "src/env.sample": "x"}
469
+ unrelated = {"src/.envrc": "x", "src/env.d.ts": "x", "src/vite-env.d.ts": "x"}
470
+ r = check(make_repo(tmp, {"README.md": "x", **bad, **templates, **unrelated}, manifest=m))
471
+ assert r.returncode == 1, r.stdout
472
+ for p in bad:
473
+ assert f"FAIL {p} -- secrets file is tracked" in r.stdout, r.stdout
474
+ for p in templates: # templates are never mistaken for secrets
475
+ assert f"{p} -- secrets" not in r.stdout and f"FAIL {p} -- scattered env template" in r.stdout, r.stdout
476
+ for p in unrelated:
477
+ assert p not in r.stdout, r.stdout
478
+
479
+
480
+ # ── detect + bootstrap ───────────────────────────────────────────────────────
481
+ # Repos go in tmp/"r" so the parent holds no other git repo (sibling detection).
482
+
483
+ def detect(root: Path) -> dict:
484
+ r = check(root, "--detect", "--json")
485
+ assert r.returncode == 0, r.stdout + r.stderr
486
+ return json.loads(r.stdout)
487
+
488
+
489
+ def test_detect_single(tmp):
490
+ d = detect(make_repo(tmp / "r", {"README.md": "x", "src/a.py": "x"}, manifest=None))
491
+ assert d["mode"] == "single" and d["sibling_repos"] == [], d
492
+ assert d["top_level"][0]["path"] == "src" and d["root_files"] == ["README.md"], d
493
+
494
+
495
+ def test_detect_monorepo_from_workspace_file(tmp):
496
+ d = detect(make_repo(tmp / "r", {"pnpm-workspace.yaml": "packages: []", "apps/web/index.ts": "x"}, manifest=None))
497
+ assert d["mode"] == "monorepo" and "pnpm-workspace.yaml" in d["reasons"][0], d
498
+
499
+
500
+ def test_detect_monorepo_from_package_json_workspaces(tmp):
501
+ d = detect(make_repo(tmp / "r", {"package.json": '{"workspaces": ["apps/*"]}'}, manifest=None))
502
+ assert d["mode"] == "monorepo", d
503
+
504
+
505
+ def test_detect_monorepo_from_two_packages(tmp):
506
+ tree = {"apps/api/pyproject.toml": "x", "apps/web/package.json": "{}"}
507
+ d = detect(make_repo(tmp / "r", tree, manifest=None))
508
+ assert d["mode"] == "monorepo" and d["packages"] == ["apps/api", "apps/web"], d
509
+
510
+
511
+ def test_detect_multi_repo_from_sibling(tmp):
512
+ subprocess.run(["git", "init", "-q", str(tmp / "other")], check=True)
513
+ d = detect(make_repo(tmp / "r", {"README.md": "x"}, manifest=None))
514
+ assert d["mode"] == "multi-repo" and d["sibling_repos"] == ["other"], d
515
+
516
+
517
+ def test_detect_environments(tmp):
518
+ tree = {"infra/env/dev/settings.yaml": "a", "infra/deploy/prod/main.tf": "b",
519
+ "docker-compose.staging.yml": "c", "infra/compose/dev.yml": "d",
520
+ ".github/workflows/deploy.yml": "jobs:\n go:\n environment: production\n"}
521
+ d = detect(make_repo(tmp / "r", tree, manifest=None))
522
+ assert list(d["environments"]) == ["dev", "staging", "prod", "production"], d["environments"]
523
+ assert "infra/compose/dev.yml" in d["environments"]["dev"], d["environments"]
524
+
525
+
526
+ def test_detect_near_empty_and_untracked_files(tmp):
527
+ d = detect(make_repo(tmp / "r", {"README.md": "x", "LICENSE": "x"}, manifest=None))
528
+ assert d["near_empty"], d
529
+ (tmp / "r/src").mkdir()
530
+ (tmp / "r/src/new.py").write_text("x") # untracked, not ignored: a new repo counts it
531
+ assert not detect(tmp / "r")["near_empty"]
532
+
533
+
534
+ def test_no_manifest_prints_options(tmp):
535
+ r = check(make_repo(tmp / "r", {"README.md": "x"}, manifest=None))
536
+ assert r.returncode == 0, r.stdout
537
+ for s in ("structure checking is off", "Detected mode: single", "monorepo", "multi-repo",
538
+ "--bootstrap", "set up the repo structure"):
539
+ assert s in r.stdout, (s, r.stdout)
540
+
541
+
542
+ def test_bootstrap_writes_a_manifest_that_passes(tmp):
543
+ root = make_repo(tmp / "r", {"README.md": "x", "src/a.py": "x", "tools/b.sh": "x"}, manifest=None)
544
+ r = check(root, "--bootstrap")
545
+ assert r.returncode == 0 and "Wrote .claude/structure.toml" in r.stdout, r.stdout
546
+ text = (root / ".claude/structure.toml").read_text()
547
+ assert 'path = "src"' in text and 'path = "tools"' in text and "TODO" in text, text
548
+ subprocess.run(["git", "add", "-A"], cwd=root, check=True)
549
+ r = check(root)
550
+ assert r.returncode == 0, r.stdout
551
+
552
+
553
+ def test_bootstrap_never_overwrites(tmp):
554
+ root = make_repo(tmp / "r", {"README.md": "x"})
555
+ before = (root / ".claude/structure.toml").read_text()
556
+ r = check(root, "--bootstrap")
557
+ assert r.returncode == 2 and "already exists" in r.stdout, r.stdout
558
+ assert (root / ".claude/structure.toml").read_text() == before
559
+
560
+
561
+ def test_bootstrap_mode_override_and_commented_suggestions(tmp):
562
+ subprocess.run(["git", "init", "-q", str(tmp / "api")], check=True)
563
+ root = make_repo(tmp / "r", {"README.md": "x", "infra/env/dev/.env.example": "a"}, manifest=None)
564
+ assert check(root, "--bootstrap", "--mode", "multi-repo").returncode == 0
565
+ text = (root / ".claude/structure.toml").read_text()
566
+ assert 'mode = "multi-repo"' in text, text
567
+ assert '# name = "api"' in text and '# path = "../api"' in text, text # sibling pointer, commented
568
+ assert '# name = "dev"' in text and '# target = "local"' in text, text # seen env, commented
569
+
570
+
571
+ def test_bootstrap_warns_about_tracked_secrets_and_never_baselines_them(tmp):
572
+ """A root .env is ALSO an unlisted root file; baselining that exact path would silence the
573
+ secrets FAIL too (found adopting a real repo). Bootstrap must leave secrets failing."""
574
+ root = make_repo(tmp / "r", {"README.md": "x", ".env": "S=1", "src/.env": "S=1", "notes.txt": "x"},
575
+ manifest=None)
576
+ r = check(root, "--bootstrap", "--layout", "single")
577
+ assert r.returncode == 0 and "FAIL .env " in r.stdout and "FAIL src/.env " in r.stdout, r.stdout
578
+ text = (root / ".claude/structure.toml").read_text()
579
+ assert '"notes.txt"' in text and '".env"' not in text, text
580
+ subprocess.run(["git", "add", "-A"], cwd=root, check=True)
581
+ r = check(root)
582
+ assert r.returncode == 1 and "FAIL .env " in r.stdout and "FAIL src/.env " in r.stdout, r.stdout
583
+ assert "FAIL .env -- root file" not in r.stdout, "a secret must not also be reported as an unlisted root file"
584
+
585
+
586
+ def test_bootstrap_unknown_layout_exits_2(tmp):
587
+ r = check(make_repo(tmp / "r", {"README.md": "x"}, manifest=None), "--bootstrap", "--layout", "nope")
588
+ assert r.returncode == 2 and "no layout 'nope'" in r.stdout, r.stdout
589
+
590
+
591
+ # ── layouts ──────────────────────────────────────────────────────────────────
592
+
593
+ LAYOUTS = REPO / ".claude/templates/structure"
594
+
595
+
596
+ def test_every_shipped_layout_is_a_valid_manifest(tmp):
597
+ names = sorted(p.stem for p in LAYOUTS.glob("*.toml"))
598
+ assert names == ["monorepo", "multi-repo", "single"], names
599
+ for name in names:
600
+ root = make_repo(tmp / name, {"README.md": "x"}, manifest=(LAYOUTS / f"{name}.toml").read_text())
601
+ r = check(root)
602
+ assert r.returncode in (0, 1), f"{name}: {r.stdout}" # 2 would mean an invalid manifest
603
+ assert f'mode: {name}' in r.stdout, r.stdout
604
+
605
+
606
+ def test_bootstrap_from_layout_adopts_an_existing_repo(tmp):
607
+ tree = {"README.md": "x", "src/a.py": "x", "infra/env/.env.example": "a",
608
+ "legacy/old.py": "x", "notes.txt": "x", "infra/compose/notes.txt": "x"}
609
+ root = make_repo(tmp / "r", tree, manifest=None)
610
+ r = check(root, "--bootstrap", "--layout", "single")
611
+ assert r.returncode == 0 and "layout: single" in r.stdout, r.stdout
612
+ text = (root / ".claude/structure.toml").read_text()
613
+ # folders that exist are active, the rest planned
614
+ assert 'path = "src"\npurpose = "Application source"\nstatus = "active"' in text, text
615
+ assert 'path = "migrations"' in text and 'status = "planned"' in text, text
616
+ # the existing structure wins: an unknown top-level folder becomes its own entry...
617
+ assert 'path = "legacy"\npurpose = "TODO: what belongs here (not in the single layout)"' in text, text
618
+ # ...and the baseline is exact paths, never a glob that would allow future files
619
+ assert 'exceptions = ["infra/compose/notes.txt", "notes.txt"]' in text, text
620
+ subprocess.run(["git", "add", "-A"], cwd=root, check=True)
621
+ r = check(root)
622
+ assert r.returncode == 0 and "allowed by exceptions" in r.stdout, r.stdout
623
+ # ...and a NEW violation still blocks, including next to a baselined one
624
+ (root / "stray.txt").write_text("x")
625
+ (root / "infra/compose/todo.txt").write_text("x")
626
+ subprocess.run(["git", "add", "-A"], cwd=root, check=True)
627
+ r = check(root, "--staged")
628
+ assert r.returncode == 1 and "FAIL stray.txt" in r.stdout and "FAIL infra/compose/todo.txt" in r.stdout, r.stdout
629
+
630
+
631
+ def test_bootstrap_from_layout_on_a_new_repo_plans_everything(tmp):
632
+ root = make_repo(tmp / "r", {"README.md": "x"}, manifest=None)
633
+ assert check(root, "--bootstrap", "--layout", "monorepo").returncode == 0
634
+ text = (root / ".claude/structure.toml").read_text()
635
+ assert 'status = "active"' not in text and "exceptions" not in text, text
636
+ assert 'name = "stg"' in text and 'mirrors = "prod"' in text, text
637
+
638
+
639
+ # ── skill ────────────────────────────────────────────────────────────────────
640
+
641
+ def test_skill_only_names_flags_and_paths_that_exist():
642
+ """The skill drives the script: a renamed flag or moved template breaks it silently."""
643
+ skill = (REPO / "skills/structure-bootstrap/SKILL.md").read_text()
644
+ assert skill.startswith("---\nname: structure-bootstrap\n"), skill[:60]
645
+ helptext = subprocess.run([sys.executable, str(SCRIPT), "--help"], capture_output=True, text=True).stdout
646
+ git_flags = {"--no-verify", "--cached"}
647
+ for flag in sorted(set(re.findall(r"(--[a-z][a-z-]+)", skill)) - git_flags):
648
+ assert flag in helptext, f"skill names {flag}, script has no such flag"
649
+ for path in (".claude/project-map/structure-check.py", ".claude/templates/structure"):
650
+ assert path in skill and (REPO / path).exists(), path
651
+
652
+
653
+ # ── hook + installer ─────────────────────────────────────────────────────────
654
+
655
+ def _block(text: str) -> str:
656
+ return text[text.index("# ── Structure Check"):text.index("# ── End Structure Check")]
657
+
658
+
659
+ def test_installer_writes_the_same_hook_block():
660
+ assert _block((REPO / ".githooks/pre-commit").read_text()) == _block((REPO / ".claude/install.sh").read_text())
661
+
662
+
663
+ def test_install_copies_the_check_and_never_touches_the_manifest(tmp):
664
+ root = make_repo(tmp / "p", {"README.md": "x"}, manifest='mode = "single"\n# hand-written\n', commit=False)
665
+ before = (root / ".claude/structure.toml").read_bytes()
666
+ for _ in range(2): # re-running (init / update) must be just as safe
667
+ r = subprocess.run(["bash", str(REPO / ".claude/install.sh"), str(root)], capture_output=True, text=True)
668
+ assert r.returncode == 0, r.stdout[-2000:] + r.stderr[-2000:]
669
+ assert (root / ".claude/structure.toml").read_bytes() == before
670
+ assert (root / ".claude/project-map/structure-check.py").is_file()
671
+ assert sorted(p.name for p in (root / ".claude/templates/structure").glob("*.toml")) == \
672
+ ["monorepo.toml", "multi-repo.toml", "single.toml"]
673
+ hook = (root / ".githooks/pre-commit").read_text()
674
+ assert hook.count("# ── Structure Check") == 1, "re-running the installer duplicated the block"
675
+
676
+
677
+ def test_installer_checksum_matches_install_sh():
678
+ """The curl installer verifies this hash first; editing install.sh without it breaks installs (runbook)."""
679
+ import hashlib
680
+ want = json.loads((REPO / "checksums.json").read_text())["install_sh"]
681
+ assert hashlib.sha256((REPO / ".claude/install.sh").read_bytes()).hexdigest() == want, \
682
+ "regenerate checksums.json: sha256sum .claude/install.sh"
683
+
684
+
685
+ def test_installer_upgrades_a_hook_from_before_the_structure_check(tmp):
686
+ root = make_repo(tmp / "p", {"README.md": "x"}, manifest=None, commit=False)
687
+ current = (REPO / ".githooks/pre-commit").read_text()
688
+ old = current[:current.index("# ── Structure Check")].rstrip() + "\n" # a 2.5.0-era hook
689
+ assert "Context Check" in old and "Structure Check" not in old
690
+ (root / ".githooks").mkdir()
691
+ (root / ".githooks/pre-commit").write_text(old)
692
+ r = subprocess.run(["bash", str(REPO / ".claude/install.sh"), str(root)], capture_output=True, text=True)
693
+ assert r.returncode == 0, r.stdout[-2000:] + r.stderr[-2000:]
694
+ hook = (root / ".githooks/pre-commit").read_text()
695
+ assert hook.count("# ── Structure Check") == 1 and hook.count("# ── Context Check") == 1, hook
696
+
697
+
698
+ def test_submodules_are_paths_like_any_other(tmp):
699
+ lib = make_repo(tmp / "lib", {"x.py": "x"}, manifest=None)
700
+ root = make_repo(tmp / "r", {"README.md": "x"}, manifest=None)
701
+ subprocess.run(GIT + ["-c", "protocol.file.allow=always", "submodule", "-q", "add", str(lib), "libs/vendor"],
702
+ cwd=root, check=True, capture_output=True)
703
+ d = detect(root)
704
+ assert d["submodules"] and any(".gitmodules" in r for r in d["reasons"]), d
705
+ (root / ".claude").mkdir()
706
+ (root / ".claude/structure.toml").write_text('root_files = ["README.md", ".gitmodules"]\n\n[[folder]]\npath = "libs"\n')
707
+ subprocess.run(["git", "add", "-A"], cwd=root, check=True)
708
+ r = check(root)
709
+ assert r.returncode == 0, r.stdout # the gitlink is one path under libs/; its contents aren't ours
710
+ (root / ".claude/structure.toml").write_text('root_files = ["README.md", ".gitmodules"]\n')
711
+ r = check(root)
712
+ assert r.returncode == 1 and "FAIL libs/vendor " in r.stdout, r.stdout
713
+
714
+
715
+ def test_moving_a_file_into_a_deprecated_folder_counts_as_new(tmp):
716
+ make_repo(tmp, {"README.md": "x", "src/a.py": "x", "legacy/old.py": "y"},
717
+ manifest=DEPRECATED.replace('["**/*.py"]', '["**"]'))
718
+ subprocess.run(["git", "checkout", "-qb", "feature"], cwd=tmp, check=True)
719
+ subprocess.run(["git", "mv", "src/a.py", "legacy/a.py"], cwd=tmp, check=True)
720
+ subprocess.run(GIT + ["commit", "-qm", "move"], cwd=tmp, check=True)
721
+ r = check(tmp, "--since", "main")
722
+ assert r.returncode == 1 and "legacy/a.py" in r.stdout and "legacy/old.py" not in r.stdout, r.stdout
723
+
724
+
725
+ def test_since_on_a_shallow_clone_explains_fetch_depth(tmp):
726
+ origin = make_repo(tmp / "origin", {"README.md": "x", "src/a.py": "x"})
727
+ subprocess.run(["git", "checkout", "-qb", "feature"], cwd=origin, check=True)
728
+ for i in range(3):
729
+ (origin / f"src/f{i}.py").write_text(str(i))
730
+ subprocess.run(["git", "add", "-A"], cwd=origin, check=True)
731
+ subprocess.run(GIT + ["commit", "-qm", f"c{i}"], cwd=origin, check=True)
732
+ subprocess.run(["git", "checkout", "-q", "main"], cwd=origin, check=True)
733
+ (origin / "src/m.py").write_text("main moved on")
734
+ subprocess.run(["git", "add", "-A"], cwd=origin, check=True)
735
+ subprocess.run(GIT + ["commit", "-qm", "main"], cwd=origin, check=True)
736
+ ci = tmp / "ci"
737
+ subprocess.run(["git", "clone", "-q", "--depth", "1", "--branch", "feature", f"file://{origin}", str(ci)], check=True)
738
+ subprocess.run(["git", "fetch", "-q", "--depth", "1", "origin", "main:refs/remotes/origin/main"], cwd=ci, check=True)
739
+ r = check(ci, "--since", "origin/main")
740
+ assert r.returncode == 2 and "fetch-depth: 0" in r.stdout, r.stdout
741
+
742
+
743
+ def test_hook_blocks_a_misplaced_file_only_once_there_is_a_manifest(tmp):
744
+ root = make_repo(tmp / "p", {"README.md": "x"}, manifest=None)
745
+ (root / ".githooks").mkdir()
746
+ shutil.copy(REPO / ".githooks/pre-commit", root / ".githooks/pre-commit")
747
+ (root / ".githooks/pre-commit").chmod(0o755)
748
+ (root / ".claude/project-map").mkdir(parents=True)
749
+ shutil.copy(SCRIPT, root / ".claude/project-map/structure-check.py")
750
+ git = GIT + ["-c", "core.hooksPath=.githooks"]
751
+
752
+ def commit(msg: str) -> subprocess.CompletedProcess:
753
+ subprocess.run(["git", "add", "-A"], cwd=root, check=True)
754
+ return subprocess.run(git + ["commit", "-qm", msg], cwd=root, capture_output=True, text=True)
755
+
756
+ (root / "stray.txt").write_text("x")
757
+ assert commit("no manifest: check is off").returncode == 0
758
+
759
+ (root / ".claude/structure.toml").write_text(
760
+ 'root_files = ["README.md", "stray.txt"]\n\n[[folder]]\npath = ".githooks"\n\n'
761
+ '[[folder]]\npath = ".claude"\n')
762
+ assert commit("manifest").returncode == 0
763
+
764
+ (root / "tools").mkdir()
765
+ (root / "tools/x.sh").write_text("x")
766
+ r = commit("misplaced")
767
+ assert r.returncode != 0, "hook let a file under no manifest folder through"
768
+ out = r.stdout + r.stderr # git hands a hook's stdout to stderr
769
+ assert "[structure-check] Commit blocked" in out and "FAIL tools/x.sh" in out, out
770
+
771
+
772
+ REALISTIC_MONOREPO = {
773
+ "README.md": "x", "package.json": '{"workspaces": ["apps/*", "packages/*"]}', "pnpm-workspace.yaml": "p",
774
+ "apps/api/package.json": '{"name": "api"}', "apps/api/src/index.ts": "api",
775
+ "apps/api/migrations/001_init.sql": "create", "apps/api/src/seed/dev-users.ts": "seed code",
776
+ "apps/web/package.json": '{"name": "web"}', "apps/web/src/pages/index.tsx": "web",
777
+ "apps/web/src/features/prod-banner.tsx": "a component that merely mentions prod",
778
+ "apps/web/config/environments/production.rb": "rails-style per-env FILE, not a folder",
779
+ "packages/ui/package.json": '{"name": "ui"}', "packages/ui/src/button.tsx": "btn",
780
+ "infra/compose/base.yml": "services: {api: {}}", "infra/compose/dev.yml": "dev override",
781
+ "infra/compose/remote.yml": "remote override",
782
+ "infra/env/.env.example": "API_URL=\nDATABASE_URL=\n", "infra/env/README.md": "how env config works",
783
+ "infra/deploy/base/main.tf": "module", "infra/deploy/base/variables.tf": "vars",
784
+ "infra/deploy/stg/main.tf": 'module "app" { source = "../base" }',
785
+ "infra/deploy/prod/main.tf": 'module "app" { source = "../base" }', # identical ACROSS envs is fine
786
+ "infra/deploy/stg/terraform.tfvars": "size = small", "infra/deploy/prod/terraform.tfvars": "size = large",
787
+ "k8s/base/deployment.yaml": "deploy", "k8s/base/kustomization.yaml": "base",
788
+ "k8s/overlays/stg/kustomization.yaml": "stg patch", "k8s/overlays/prod/kustomization.yaml": "prod patch",
789
+ ".github/workflows/deploy.yml": "on: workflow_dispatch\njobs:\n d:\n environment: ${{ inputs.env }}\n",
790
+ ".github/workflows/ci.yml": "ci",
791
+ }
792
+
793
+
794
+ def test_realistic_monorepo_with_environments_passes_clean(tmp):
795
+ """False-positive guard: common, correct patterns must pass untouched."""
796
+ m = (REPO / ".claude/templates/structure/monorepo.toml").read_text()
797
+ m = m.replace('mode = "monorepo"', 'mode = "monorepo"\nenv_roots = ["infra/env", "infra/deploy", "k8s/overlays"]')
798
+ m += '\n[[folder]]\npath = "k8s"\npurpose = "Kustomize base + per-env overlays"\n'
799
+ r = check(make_repo(tmp / "r", REALISTIC_MONOREPO, manifest=m))
800
+ assert r.returncode == 0 and "FAIL" not in r.stdout, r.stdout
801
+ d = detect(tmp / "r")
802
+ assert d["mode"] == "monorepo" and set(d["environments"]) >= {"dev", "stg", "prod"}, d
803
+
804
+
805
+ def test_realistic_monorepo_catches_the_classic_drift(tmp):
806
+ m = (REPO / ".claude/templates/structure/monorepo.toml").read_text()
807
+ m = m.replace('mode = "monorepo"', 'mode = "monorepo"\nenv_roots = ["infra/env", "infra/deploy", "k8s/overlays"]')
808
+ m += '\n[[folder]]\npath = "k8s"\npurpose = "Kustomize base + per-env overlays"\n'
809
+ drift = {
810
+ "apps/api/config/prod/settings.yaml": "per-app env config", # per-app config/<env>/
811
+ "apps/api/env/prod/settings.yaml": "per-app env folder", # per-app env folder
812
+ "apps/web/.env.example": "NEXT_PUBLIC_X=", # scattered template (per-app)
813
+ "infra/env/prod/.env.example": "API_URL=", # scattered template (per-env)
814
+ "infra/deploy/prod/redis.tf": "redis only in prod", # stg no longer mirrors prod
815
+ "infra/deploy/prod/variables.tf": "vars", # copy of base/variables.tf
816
+ "infra/env/qa/settings.yaml": "QA=1", # undeclared environment
817
+ "apps/api/migrations/prod/002_hotfix.sql": "prod-only migration", # per-env migration
818
+ "docker-compose.yml": "compose at the root", # compose outside infra/compose
819
+ }
820
+ r = check(make_repo(tmp / "r", {**REALISTIC_MONOREPO, **drift}, manifest=m))
821
+ assert r.returncode == 1, r.stdout
822
+ for needle in ("FAIL apps/web/.env.example -- scattered env template",
823
+ "FAIL infra/env/prod/.env.example -- scattered env template",
824
+ "apps/api/config/prod/", "apps/api/env/prod/", "infra/deploy/stg/redis.tf", "identical copy of infra/deploy/base/variables.tf",
825
+ "infra/env/qa/", "apps/api/migrations/prod/002_hotfix.sql", "FAIL docker-compose.yml"):
826
+ assert needle in r.stdout, (needle, r.stdout)
827
+
828
+
829
+ def test_old_python_warns_and_skips(tmp):
830
+ """Python < 3.11 has no tomllib: a readable WARN, exit 0, never a block."""
831
+ make_repo(tmp, {"README.md": "x"})
832
+ code = ("import sys, runpy; sys.modules['tomllib'] = None; "
833
+ f"sys.argv = ['structure-check.py', '--project-root', {str(tmp)!r}]; "
834
+ f"runpy.run_path({str(SCRIPT)!r}, run_name='__main__')")
835
+ r = subprocess.run([sys.executable, "-c", code], capture_output=True, text=True)
836
+ assert r.returncode == 0, r.stdout + r.stderr
837
+ assert "WARN" in r.stdout and "3.11" in r.stdout and "Nothing was checked" in r.stdout, r.stdout
838
+
839
+
840
+ def main() -> int:
841
+ tests = [v for k, v in sorted(globals().items()) if k.startswith("test_")]
842
+ failed = []
843
+ base = Path(tempfile.mkdtemp(prefix="structure-check-test-"))
844
+ try:
845
+ for fn in tests:
846
+ d = base / fn.__name__
847
+ d.mkdir(parents=True)
848
+ try:
849
+ if "tmp" in inspect.signature(fn).parameters:
850
+ fn(d)
851
+ else:
852
+ fn()
853
+ print(f" ok {fn.__name__}")
854
+ except Exception as e:
855
+ failed.append(fn.__name__)
856
+ print(f" FAIL {fn.__name__}: {type(e).__name__}: {e}")
857
+ finally:
858
+ shutil.rmtree(base, ignore_errors=True)
859
+ print(f"\n{len(tests) - len(failed)}/{len(tests)} passed")
860
+ return 1 if failed else 0
861
+
862
+
863
+ if __name__ == "__main__":
864
+ sys.exit(main())