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