Skip to content

Commit c764c5d

Browse files
baijumclaude
andcommitted
feat: add 5 best-practice checks and 16 tests to validator (37 → 53)
New Tier 2 WARN checks: __init__.py, .dockerignore, CORS/rate-limit middleware, migration file structure, health endpoint in code. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent c861ed0 commit c764c5d

6 files changed

Lines changed: 200 additions & 0 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
.git
2+
.github
3+
__pycache__
4+
.env
5+
*.md

tests/fixtures/valid-app/app/__init__.py

Whitespace-only changes.
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
"""initial migration"""
2+
revision = "0001"

tests/fixtures/valid-app/app/main.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
from fastapi import FastAPI
2+
from fastapi.middleware.cors import CORSMiddleware
3+
from slowapi import Limiter
24

35
app = FastAPI()
46

tests/test_validator.py

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -204,6 +204,113 @@ def test_no_alembic_dep_skips(self, tmp_path):
204204
assert has_result(v, Result.SKIP, "Alembic config")
205205

206206

207+
class TestTier2InitPy:
208+
def test_init_py_present_passes(self):
209+
v = run_validator(VALID_APP, tier=2)
210+
assert has_result(v, Result.PASS, "app/__init__.py exists")
211+
212+
def test_init_py_missing_warns(self, tmp_path):
213+
(tmp_path / "app").mkdir()
214+
v = Validator(app_dir=str(tmp_path), tier=2, strict=False)
215+
v._check_init_py()
216+
assert has_result(v, Result.WARN, "app/__init__.py exists")
217+
218+
219+
class TestTier2Dockerignore:
220+
def test_dockerignore_all_present_passes(self):
221+
v = run_validator(VALID_APP, tier=2)
222+
assert has_result(v, Result.PASS, ".dockerignore exists")
223+
assert has_result(v, Result.PASS, ".dockerignore excludes .git")
224+
assert has_result(v, Result.PASS, ".dockerignore excludes .env")
225+
226+
def test_dockerignore_missing_warns(self, tmp_path):
227+
v = Validator(app_dir=str(tmp_path), tier=2, strict=False)
228+
v._check_dockerignore()
229+
assert has_result(v, Result.WARN, ".dockerignore exists")
230+
231+
def test_dockerignore_missing_git_warns(self, tmp_path):
232+
(tmp_path / ".dockerignore").write_text(".env\n")
233+
v = Validator(app_dir=str(tmp_path), tier=2, strict=False)
234+
v._check_dockerignore()
235+
assert has_result(v, Result.WARN, ".dockerignore excludes .git")
236+
237+
def test_dockerignore_missing_env_warns(self, tmp_path):
238+
(tmp_path / ".dockerignore").write_text(".git\n")
239+
v = Validator(app_dir=str(tmp_path), tier=2, strict=False)
240+
v._check_dockerignore()
241+
assert has_result(v, Result.WARN, ".dockerignore excludes .env")
242+
243+
244+
class TestTier2Middleware:
245+
def test_both_middleware_passes(self):
246+
v = run_validator(VALID_APP, tier=2)
247+
assert has_result(v, Result.PASS, "CORS middleware referenced")
248+
assert has_result(v, Result.PASS, "Rate limiting referenced")
249+
250+
def test_missing_cors_warns(self, tmp_path):
251+
(tmp_path / "app").mkdir()
252+
(tmp_path / "app" / "main.py").write_text("from fastapi import FastAPI\nfrom slowapi import Limiter\napp = FastAPI()\n")
253+
v = Validator(app_dir=str(tmp_path), tier=2, strict=False)
254+
v._check_middleware()
255+
assert has_result(v, Result.WARN, "CORS middleware referenced")
256+
257+
def test_missing_rate_limiting_warns(self, tmp_path):
258+
(tmp_path / "app").mkdir()
259+
(tmp_path / "app" / "main.py").write_text("from fastapi import FastAPI\nfrom fastapi.middleware.cors import CORSMiddleware\napp = FastAPI()\n")
260+
v = Validator(app_dir=str(tmp_path), tier=2, strict=False)
261+
v._check_middleware()
262+
assert has_result(v, Result.WARN, "Rate limiting referenced")
263+
264+
265+
class TestTier2MigrationStructure:
266+
def test_migration_structure_passes(self):
267+
v = run_validator(VALID_APP, tier=2)
268+
assert has_result(v, Result.PASS, "Migration versions present")
269+
270+
def test_no_alembic_dep_skips(self, tmp_path):
271+
(tmp_path / "requirements.txt").write_text("fastapi\nuvicorn\n")
272+
v = Validator(app_dir=str(tmp_path), tier=2, strict=False)
273+
v._check_migration_structure()
274+
assert has_result(v, Result.SKIP, "Migration structure")
275+
276+
def test_missing_versions_dir_warns(self, tmp_path):
277+
(tmp_path / "requirements.txt").write_text("fastapi\nalembic\n")
278+
(tmp_path / "app").mkdir()
279+
(tmp_path / "app" / "alembic").mkdir()
280+
v = Validator(app_dir=str(tmp_path), tier=2, strict=False)
281+
v._check_migration_structure()
282+
assert has_result(v, Result.WARN, "Migration versions directory")
283+
284+
def test_empty_versions_dir_warns(self, tmp_path):
285+
(tmp_path / "requirements.txt").write_text("fastapi\nalembic\n")
286+
(tmp_path / "app" / "alembic" / "versions").mkdir(parents=True)
287+
v = Validator(app_dir=str(tmp_path), tier=2, strict=False)
288+
v._check_migration_structure()
289+
assert has_result(v, Result.WARN, "Migration versions present")
290+
291+
292+
class TestTier2HealthEndpointCode:
293+
def test_health_in_main_passes(self):
294+
v = run_validator(VALID_APP, tier=2)
295+
assert has_result(v, Result.PASS, "Health endpoint in code")
296+
297+
def test_no_health_endpoint_warns(self, tmp_path):
298+
(tmp_path / "app").mkdir()
299+
(tmp_path / "app" / "main.py").write_text("from fastapi import FastAPI\napp = FastAPI()\n")
300+
v = Validator(app_dir=str(tmp_path), tier=2, strict=False)
301+
v._check_health_endpoint_code()
302+
assert has_result(v, Result.WARN, "Health endpoint in code")
303+
304+
def test_health_in_router_passes(self, tmp_path):
305+
(tmp_path / "app").mkdir()
306+
(tmp_path / "app" / "main.py").write_text("from fastapi import FastAPI\napp = FastAPI()\n")
307+
(tmp_path / "app" / "routers").mkdir()
308+
(tmp_path / "app" / "routers" / "health.py").write_text('@router.get("/health")\ndef health(): return {"status":"ok"}\n')
309+
v = Validator(app_dir=str(tmp_path), tier=2, strict=False)
310+
v._check_health_endpoint_code()
311+
assert has_result(v, Result.PASS, "Health endpoint in code")
312+
313+
207314
class TestTier3WithMocking:
208315
"""Tier 3 tests using mocked subprocess.run — no Docker required."""
209316

validator/validate.py

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,11 @@ def check_content(self):
130130
self._check_alembic_config()
131131
self._check_resource_limits()
132132
self._check_read_only_fs()
133+
self._check_init_py()
134+
self._check_dockerignore()
135+
self._check_middleware()
136+
self._check_migration_structure()
137+
self._check_health_endpoint_code()
133138

134139
def _check_compose(self):
135140
content = self._read("deploy", "docker-compose.yml")
@@ -384,6 +389,85 @@ def _check_read_only_fs(self):
384389
else:
385390
self._record(Result.WARN, "Read-only filesystem enabled", "read_only: true not found")
386391

392+
def _check_init_py(self):
393+
if self._exists("app", "__init__.py"):
394+
self._record(Result.PASS, "app/__init__.py exists")
395+
else:
396+
self._record(Result.WARN, "app/__init__.py exists", "missing — app/ may not be importable as a package")
397+
398+
def _check_dockerignore(self):
399+
content = self._read(".dockerignore")
400+
if content is None:
401+
self._record(Result.WARN, ".dockerignore exists", "missing — builds may include unnecessary files")
402+
return
403+
404+
self._record(Result.PASS, ".dockerignore exists")
405+
406+
if ".git" in content:
407+
self._record(Result.PASS, ".dockerignore excludes .git")
408+
else:
409+
self._record(Result.WARN, ".dockerignore excludes .git", ".git not excluded")
410+
411+
if ".env" in content:
412+
self._record(Result.PASS, ".dockerignore excludes .env")
413+
else:
414+
self._record(Result.WARN, ".dockerignore excludes .env", ".env not excluded — secrets may leak into image")
415+
416+
def _check_middleware(self):
417+
content = self._read("app", "main.py")
418+
if content is None:
419+
self._record(Result.SKIP, "Middleware checks", "app/main.py missing")
420+
return
421+
422+
if re.search(r"CORS|CORSMiddleware|cors", content):
423+
self._record(Result.PASS, "CORS middleware referenced")
424+
else:
425+
self._record(Result.WARN, "CORS middleware referenced", "no CORS reference found in app/main.py")
426+
427+
if re.search(r"slowapi|Limiter|RateLimiter|rate.limit", content, re.IGNORECASE):
428+
self._record(Result.PASS, "Rate limiting referenced")
429+
else:
430+
self._record(Result.WARN, "Rate limiting referenced", "no rate limiting reference found in app/main.py")
431+
432+
def _check_migration_structure(self):
433+
deps_content = self._read("requirements.txt") or self._read("pyproject.toml") or ""
434+
if "alembic" not in deps_content.lower():
435+
self._record(Result.SKIP, "Migration structure", "alembic not in dependencies")
436+
return
437+
438+
versions_dir = self._path("app", "alembic", "versions")
439+
if not os.path.isdir(versions_dir):
440+
self._record(Result.WARN, "Migration versions directory", "app/alembic/versions/ not found")
441+
return
442+
443+
py_files = [f for f in os.listdir(versions_dir) if f.endswith(".py") and f != "__init__.py"]
444+
if py_files:
445+
self._record(Result.PASS, "Migration versions present")
446+
else:
447+
self._record(Result.WARN, "Migration versions present", "app/alembic/versions/ is empty")
448+
449+
def _check_health_endpoint_code(self):
450+
found = False
451+
# Check app/main.py
452+
content = self._read("app", "main.py")
453+
if content and re.search(r'["\'/]health', content):
454+
found = True
455+
456+
# Check app/routers/*.py
457+
routers_dir = self._path("app", "routers")
458+
if os.path.isdir(routers_dir):
459+
for fname in os.listdir(routers_dir):
460+
if fname.endswith(".py"):
461+
router_content = self._read("app", "routers", fname)
462+
if router_content and re.search(r'["\'/]health', router_content):
463+
found = True
464+
break
465+
466+
if found:
467+
self._record(Result.PASS, "Health endpoint in code")
468+
else:
469+
self._record(Result.WARN, "Health endpoint in code", "no /health route found in app/main.py or app/routers/")
470+
387471
# ── Tier 3: Runtime ────────────────────────────────────────────────
388472

389473
def check_runtime(self):

0 commit comments

Comments
 (0)