Skip to content

Commit fd0dc55

Browse files
committed
feat: enhance registry validator with repository consistency checks
Add repository consistency validation to detect project directories that exist in the repository but are missing from the registry. Improve project discovery by scanning repository project directories and comparing them against registered entries. Enhance validation reporting with repository consistency information in both human-readable and JSON output. Add comprehensive unit tests covering: - Repository consistency validation - Unregistered project detection - Successful consistency validation - Existing registry validation behavior This helps maintainers keep projects_registry.json synchronized with the repository structure.
1 parent d0e223b commit fd0dc55

2 files changed

Lines changed: 185 additions & 3 deletions

File tree

tests/test_registry_validator.py

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -188,7 +188,11 @@ def test_missing_project_file(tmp_path):
188188
validator = RegistryValidator(registry)
189189
validator.validate()
190190

191-
assert any("missing project file" in e.lower() for e in validator.errors)
191+
assert any(
192+
"registered project not found in repository"
193+
in e.lower()
194+
for e in validator.errors
195+
)
192196

193197
def test_invalid_json(tmp_path):
194198
registry = tmp_path / "projects_registry.json"
@@ -300,7 +304,79 @@ def test_json_report(tmp_path, capsys):
300304

301305
output = json.loads(captured.out)
302306

307+
assert "repository_consistency" in output
308+
309+
assert output["repository_consistency"] == {
310+
"unregistered_projects": []
311+
}
312+
303313
assert output["projects"] == 1
304314
assert output["errors"] == 0
305315
assert output["warnings"] == 0
306-
assert output["status"] == "passed"
316+
assert output["status"] == "passed"
317+
318+
319+
def test_unregistered_repository_project(tmp_path):
320+
"""Repository project should be reported if missing from registry."""
321+
322+
utilities = tmp_path / "utilities"
323+
utilities.mkdir()
324+
325+
project = utilities / "DemoProject"
326+
project.mkdir()
327+
328+
(project / "DemoProject.py").write_text(
329+
"print('hello')",
330+
encoding="utf-8",
331+
)
332+
333+
registry = write_registry(tmp_path, [])
334+
335+
validator = RegistryValidator(registry)
336+
337+
validator.validate()
338+
339+
assert "utilities/DemoProject" in validator.unregistered_projects
340+
341+
assert any(
342+
"missing from registry" in error.lower()
343+
for error in validator.errors
344+
)
345+
346+
347+
def test_repository_consistency_passes(tmp_path):
348+
"""Registry and repository should be fully synchronized."""
349+
350+
utilities = tmp_path / "utilities"
351+
utilities.mkdir()
352+
353+
project = utilities / "DemoProject"
354+
project.mkdir()
355+
356+
(project / "DemoProject.py").write_text(
357+
"print('hello')",
358+
encoding="utf-8",
359+
)
360+
361+
registry = write_registry(
362+
tmp_path,
363+
[
364+
{
365+
"name": "Demo",
366+
"emoji": "🔥",
367+
"category": "utilities",
368+
"difficulty": "beginner",
369+
"description": "Demo project",
370+
"keywords": ["demo"],
371+
"path": "utilities/DemoProject/DemoProject.py",
372+
}
373+
],
374+
)
375+
376+
validator = RegistryValidator(registry)
377+
378+
validator.validate()
379+
380+
assert validator.unregistered_projects == []
381+
382+

utils/registry_validator.py

Lines changed: 107 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,7 @@ def __init__(self, registry_path: str = "projects_registry.json"):
4242
self.registry_path = Path(registry_path)
4343
self.errors = []
4444
self.warnings = []
45+
self.unregistered_projects = []
4546
self.projects = []
4647
self.validation_status = "PENDING"
4748

@@ -156,9 +157,26 @@ def validate_project_paths(self):
156157

157158
if not project_file.exists():
158159
self.errors.append(
159-
f"Missing project file: {path}"
160+
f"Registered project not found in repository: {path}"
160161
)
161162

163+
def validate_repository_consistency(self):
164+
"""Validate repository and registry consistency."""
165+
166+
registry_dirs = self.get_registry_project_directories()
167+
discovered_dirs = self.discover_repository_projects()
168+
169+
unregistered_projects = sorted(
170+
discovered_dirs - registry_dirs
171+
)
172+
173+
self.unregistered_projects = unregistered_projects
174+
175+
for project in self.unregistered_projects:
176+
self.errors.append(
177+
f"Project exists in repository but is missing from registry: {project}"
178+
)
179+
162180
def validate_keywords(self):
163181
"""Validate keywords field."""
164182

@@ -177,6 +195,76 @@ def validate_keywords(self):
177195
f"{project['name']} has no keywords."
178196
)
179197

198+
def get_scan_directories(self):
199+
"""Return repository roots that contain projects."""
200+
201+
root = self.registry_path.parent
202+
203+
return {
204+
directory.name
205+
for directory in root.iterdir()
206+
if directory.is_dir()
207+
and directory.name != "__pycache__"
208+
and not directory.name.startswith(".")
209+
}
210+
211+
def get_registry_project_directories(self):
212+
"""Return project directories defined in the registry."""
213+
214+
registry_dirs = set()
215+
216+
for project in self.projects:
217+
path = project.get("path")
218+
219+
if not path:
220+
continue
221+
222+
registry_dirs.add(
223+
Path(path).parent.as_posix()
224+
)
225+
226+
return registry_dirs
227+
228+
def discover_repository_projects(self):
229+
"""Discover project directories from the repository."""
230+
231+
root = self.registry_path.parent
232+
discovered_projects = set()
233+
234+
for directory_name in self.get_scan_directories():
235+
directory = root / directory_name
236+
237+
if (
238+
not directory.exists()
239+
or not directory.is_dir()
240+
):
241+
continue
242+
243+
# Standalone project (e.g. expression_parser)
244+
if directory_name == "expression_parser":
245+
if any(directory.glob("*.py")):
246+
discovered_projects.add(
247+
directory.relative_to(root).as_posix()
248+
)
249+
continue
250+
251+
# Category containing project folders
252+
for project_dir in directory.iterdir():
253+
if not project_dir.is_dir():
254+
continue
255+
256+
if project_dir.name == "__pycache__":
257+
continue
258+
259+
# Only consider directories that contain at least one
260+
# top-level Python file.
261+
if any(project_dir.glob("*.py")):
262+
discovered_projects.add(
263+
project_dir.relative_to(root).as_posix()
264+
)
265+
266+
return discovered_projects
267+
180268
def validate(self):
181269
"""Run all validation checks."""
182270

@@ -191,6 +279,7 @@ def validate(self):
191279
self.validate_duplicate_names()
192280
self.validate_duplicate_paths()
193281
self.validate_project_paths()
282+
self.validate_repository_consistency()
194283
self.validate_keywords()
195284

196285
if self.errors:
@@ -207,6 +296,9 @@ def report(self, json_output=False):
207296
"errors": len(self.errors),
208297
"warnings": len(self.warnings),
209298
"status": self.validation_status.lower(),
299+
"repository_consistency": {
300+
"unregistered_projects": self.unregistered_projects,
301+
},
210302
},
211303
indent=2,
212304
)
@@ -238,9 +330,23 @@ def report(self, json_output=False):
238330

239331
if self.errors:
240332
print("\nErrors:")
333+
241334
for error in self.errors:
335+
if error.startswith(
336+
"Project exists in repository but is missing from registry:"
337+
):
338+
continue
339+
242340
print(f" - {error}")
243341

342+
if self.unregistered_projects:
343+
print("\nRepository Consistency:")
344+
345+
print("\n Unregistered projects:")
346+
347+
for project in self.unregistered_projects:
348+
print(f" - {project}")
349+
244350
if self.warnings:
245351
print("\nWarnings:")
246352
for warning in self.warnings:

0 commit comments

Comments
 (0)