diff --git a/CHANGELOG.md b/CHANGELOG.md index de40e717..20524c5e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -33,6 +33,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). - Initial CHANGELOG.md setup for tracking project history - Documentation structure for future contributor updates - Added .flake8 config file to enforce consistent 88-character line limit for all contributors +- DevPath Sentinel developer tool for repository health and dataset integrity validation (#1295) +- Dataset validator to detect duplicate project IDs, duplicate project titles, missing required fields, empty required fields, and missing starter code references ### Changed diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 612b318a..fe9e2b5d 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -257,6 +257,13 @@ pytest tests/ -v If you add a new feature, add at least one corresponding test in `tests/test_basic.py`. +### Repository Validation + +Before opening a pull request, contributors are encouraged to run DevPath Sentinel. + +```bash +python -m tools.sentinel.cli + --- ## Submitting a Pull Request @@ -264,6 +271,7 @@ If you add a new feature, add at least one corresponding test in `tests/test_bas ### Before opening a PR - All 27 tests pass locally +- Run `python -m tools.sentinel.cli` to validate the project dataset before opening a pull request - You have tested the running app in your browser - Your branch is up to date with the upstream `main` branch - Your code follows the style rules above diff --git a/README.md b/README.md index 3020ad25..c5d8442d 100644 --- a/README.md +++ b/README.md @@ -189,6 +189,24 @@ All tests passed --- +### Validate the Project Dataset + +Run DevPath Sentinel to check the repository dataset for common integrity issues: + +```bash +python -m tools.sentinel.cli +``` + +The current validator checks for: + +- duplicate project IDs +- duplicate project titles +- missing required fields +- empty required fields +- missing starter code references + +--- + ## Troubleshooting
diff --git a/tests/test_sentinel_dataset_validator.py b/tests/test_sentinel_dataset_validator.py new file mode 100644 index 00000000..6e77dd0a --- /dev/null +++ b/tests/test_sentinel_dataset_validator.py @@ -0,0 +1,260 @@ +""" +Tests for the DevPath Sentinel dataset validator. +""" + +from __future__ import annotations + +import json + +from tools.sentinel.validators.dataset_validator import run + + +def create_project(**overrides): + """Create a valid project dictionary.""" + + project = { + "id": 1, + "title": "Expense Tracker", + "skills": ["Python"], + "level": "Beginner", + "interest": "Finance", + "time": "2 weeks", + "description": "Track expenses.", + "features": [ + "Add expense", + "Delete expense", + ], + "roadmap": [ + "Planning", + "Implementation", + ], + "resources": [ + "https://python.org", + ], + "starter_code": "starter_code/expense_tracker.py", + } + + project.update(overrides) + return project + + +def write_dataset(tmp_path, projects): + """Create a temporary projects.json file.""" + + data_dir = tmp_path / "data" + data_dir.mkdir() + + dataset_path = data_dir / "projects.json" + + dataset_path.write_text( + json.dumps(projects, indent=2), + encoding="utf-8", + ) + + return dataset_path + + +def create_starter_file(tmp_path, filename): + """Create a starter code file.""" + + starter_dir = tmp_path / "starter_code" + starter_dir.mkdir(exist_ok=True) + + (starter_dir / filename).write_text( + "# starter code", + encoding="utf-8", + ) + + +def test_valid_dataset(tmp_path): + """A valid dataset should pass validation.""" + + create_starter_file( + tmp_path, + "expense_tracker.py", + ) + + dataset = write_dataset( + tmp_path, + [create_project()], + ) + + result = run(dataset) + + assert result.passed is True + assert result.errors == [] + assert result.warnings == [] + + +def test_duplicate_project_ids(tmp_path): + """Duplicate IDs should produce an error.""" + + create_starter_file( + tmp_path, + "expense_tracker.py", + ) + + dataset = write_dataset( + tmp_path, + [ + create_project(id=1), + create_project( + id=1, + title="Calculator", + ), + ], + ) + + result = run(dataset) + + assert result.passed is False + + assert any( + "Duplicate project ID" + in error + for error in result.errors + ) + + +def test_duplicate_project_titles(tmp_path): + """Duplicate titles should produce an error.""" + + create_starter_file( + tmp_path, + "expense_tracker.py", + ) + + dataset = write_dataset( + tmp_path, + [ + create_project(id=1), + create_project(id=2), + ], + ) + + result = run(dataset) + + assert result.passed is False + + assert any( + "Duplicate project title" + in error + for error in result.errors + ) + + +def test_missing_required_field(tmp_path): + """Missing required fields should fail validation.""" + + create_starter_file( + tmp_path, + "expense_tracker.py", + ) + + project = create_project() + + del project["description"] + + dataset = write_dataset( + tmp_path, + [project], + ) + + result = run(dataset) + + assert result.passed is False + + assert any( + "missing required fields" + in error + for error in result.errors + ) + + +def test_empty_required_field(tmp_path): + """Empty required string fields should fail validation.""" + + create_starter_file( + tmp_path, + "expense_tracker.py", + ) + + dataset = write_dataset( + tmp_path, + [ + create_project( + title="", + ), + ], + ) + + result = run(dataset) + + assert result.passed is False + + assert any( + "empty 'title'" + in error + for error in result.errors + ) + + +def test_missing_starter_code_warning(tmp_path): + """Missing starter code should produce a warning.""" + + dataset = write_dataset( + tmp_path, + [create_project()], + ) + + result = run(dataset) + + assert result.passed is True + assert result.errors == [] + assert len(result.warnings) == 1 + + assert "[1]" in result.warnings[0] + + +def test_invalid_json(tmp_path): + """Invalid JSON should fail validation.""" + + data_dir = tmp_path / "data" + data_dir.mkdir() + + dataset = data_dir / "projects.json" + + dataset.write_text( + "{ invalid json", + encoding="utf-8", + ) + + result = run(dataset) + + assert result.passed is False + + assert any( + "Invalid JSON" + in error + for error in result.errors + ) + + +def test_missing_dataset_file(tmp_path): + """Missing dataset file should fail validation.""" + + dataset = ( + tmp_path + / "data" + / "projects.json" + ) + + result = run(dataset) + + assert result.passed is False + + assert any( + "Dataset not found" + in error + for error in result.errors + ) \ No newline at end of file diff --git a/tools/__init__.py b/tools/__init__.py new file mode 100644 index 00000000..ebb2e140 --- /dev/null +++ b/tools/__init__.py @@ -0,0 +1,3 @@ +""" +Developer tools for DevPath. +""" \ No newline at end of file diff --git a/tools/sentinel/README.md b/tools/sentinel/README.md new file mode 100644 index 00000000..c85eae99 --- /dev/null +++ b/tools/sentinel/README.md @@ -0,0 +1,37 @@ +# DevPath Sentinel + +DevPath Sentinel is a lightweight developer utility for validating repository integrity. + +It currently provides a dataset validator that checks the project dataset for common issues before changes are submitted. + +## Features + +The current validator detects: + +- Duplicate project IDs +- Duplicate project titles +- Missing required fields +- Empty required fields +- Missing starter code references + +## Usage + +Run the validator from the project root: + +```bash +python -m tools.sentinel.cli +``` + +The validator prints a summary of all checks, including any warnings or errors found in the dataset. + +## Project Structure + +``` +tools/ +└── sentinel/ + ├── cli.py + ├── models.py + ├── report.py + └── validators/ + └── dataset_validator.py +``` \ No newline at end of file diff --git a/tools/sentinel/__init__.py b/tools/sentinel/__init__.py new file mode 100644 index 00000000..7890efaa --- /dev/null +++ b/tools/sentinel/__init__.py @@ -0,0 +1,6 @@ +""" +DevPath Sentinel + +Repository health and integrity validation tool. +""" + diff --git a/tools/sentinel/cli.py b/tools/sentinel/cli.py new file mode 100644 index 00000000..ac85350b --- /dev/null +++ b/tools/sentinel/cli.py @@ -0,0 +1,26 @@ +""" +Command-line entry point for DevPath Sentinel. +""" +import sys + +from .report import print_banner, print_validation_result +from .validators.dataset_validator import run + + +def main() -> None: + """Run DevPath Sentinel.""" + + print_banner() + + result = run() + + print_validation_result(result) + + if result.errors: + sys.exit(1) + + sys.exit(0) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/tools/sentinel/models.py b/tools/sentinel/models.py new file mode 100644 index 00000000..5dcf9818 --- /dev/null +++ b/tools/sentinel/models.py @@ -0,0 +1,13 @@ +from dataclasses import dataclass, field +from typing import Any + + +@dataclass +class ValidationResult: + """Represents the outcome of a validation check.""" + + name: str + passed: bool + errors: list[str] = field(default_factory=list) + warnings: list[str] = field(default_factory=list) + details: dict[str, Any] = field(default_factory=dict) \ No newline at end of file diff --git a/tools/sentinel/report.py b/tools/sentinel/report.py new file mode 100644 index 00000000..11d03f4d --- /dev/null +++ b/tools/sentinel/report.py @@ -0,0 +1,85 @@ +""" +Utilities for printing formatted Sentinel reports. +""" + +from tools.sentinel.models import ValidationResult + + +ERROR_CHECKS = { + "duplicate_ids", + "duplicate_titles", + "missing_fields", + "empty_fields", +} + +WARNING_CHECKS = { + "missing_files", +} + + +def print_banner() -> None: + """Print the Sentinel startup banner.""" + + print("=" * 60) + print("DevPath Sentinel") + print("Repository Health & Integrity Validator") + print("=" * 60) + print() + + +def print_validation_result(result: ValidationResult) -> None: + """Print a formatted validation report.""" + + print(f"Running {result.name}...\n") + + print(f"Projects scanned : {result.details.get('projects', 0)}") + print() + + checks = result.details.get("checks", {}) + + check_order = ( + ("Duplicate IDs", "duplicate_ids"), + ("Duplicate Titles", "duplicate_titles"), + ("Required Fields", "missing_fields"), + ("Empty Fields", "empty_fields"), + ("Starter Code", "missing_files"), + ) + + passed_checks = 0 + + for label, key in check_order: + + issues = checks.get(key, []) + + if not issues: + print(f"✓ {label:.<28} PASS") + passed_checks += 1 + continue + + if key in WARNING_CHECKS: + print(f"⚠ {label:.<28} WARN ({len(issues)})") + else: + print(f"✗ {label:.<28} FAIL ({len(issues)})") + + for issue in issues: + print(f" • {issue}") + + print() + + print("=" * 60) + + print(f"Projects scanned : {result.details.get('projects', 0)}") + print(f"Checks passed : {passed_checks}") + print(f"Warnings : {len(result.warnings)}") + print(f"Errors : {len(result.errors)}") + + print() + + if result.errors: + print("Status : FAILED") + elif result.warnings: + print("Status : PASSED WITH WARNINGS") + else: + print("Status : PASSED") + + print("=" * 60) \ No newline at end of file diff --git a/tools/sentinel/validators/__init__.py b/tools/sentinel/validators/__init__.py new file mode 100644 index 00000000..7800887f --- /dev/null +++ b/tools/sentinel/validators/__init__.py @@ -0,0 +1,5 @@ +""" +Validator package. + +Concrete validators will be added in future phases. +""" \ No newline at end of file diff --git a/tools/sentinel/validators/dataset_validator.py b/tools/sentinel/validators/dataset_validator.py new file mode 100644 index 00000000..f0a6aff9 --- /dev/null +++ b/tools/sentinel/validators/dataset_validator.py @@ -0,0 +1,253 @@ +""" +Dataset validation utilities for DevPath Sentinel. +""" + +from __future__ import annotations + +import json +from collections import Counter +from pathlib import Path +from typing import Any + +from tools.sentinel.models import ValidationResult + + +REPO_ROOT = Path(__file__).resolve().parents[3] + +DATASET_PATH = REPO_ROOT / "data" / "projects.json" + +REQUIRED_FIELDS = ( + "id", + "title", + "skills", + "level", + "interest", + "time", + "description", + "features", + "roadmap", + "resources", + "starter_code", +) + + +def _load_projects(dataset_path: Path) -> list[dict[str, Any]]: + """ + Load a project dataset from disk. + """ + + with dataset_path.open("r", encoding="utf-8") as file: + return json.load(file) + + +def _validate_duplicate_ids( + projects: list[dict[str, Any]], +) -> list[str]: + """ + Find duplicate project IDs. + """ + + ids = [ + project.get("id") + for project in projects + ] + + duplicates = [ + str(project_id) + for project_id, count in Counter(ids).items() + if count > 1 + ] + + return [ + f"Duplicate project ID: {project_id}" + for project_id in sorted(duplicates) + ] + + +def _validate_duplicate_titles( + projects: list[dict[str, Any]], +) -> list[str]: + """ + Find duplicate project titles. + """ + + titles = [ + project.get("title", "").strip() + for project in projects + ] + + duplicates = [ + title + for title, count in Counter(titles).items() + if count > 1 + ] + + return [ + f'Duplicate project title: "{title}"' + for title in sorted(duplicates) + ] + + +def _validate_required_fields( + projects: list[dict[str, Any]], +) -> list[str]: + """ + Ensure every project contains all required fields. + """ + + errors: list[str] = [] + + for project in projects: + + project_id = project.get("id", "UNKNOWN") + + missing_fields = [ + field + for field in REQUIRED_FIELDS + if field not in project + ] + + if missing_fields: + + errors.append( + ( + f"Project {project_id} " + f"is missing required fields: " + f"{', '.join(missing_fields)}" + ) + ) + + return errors + +def _validate_empty_fields( + projects: list[dict[str, Any]], +) -> list[str]: + """ + Ensure required fields are not empty. + """ + + errors: list[str] = [] + + for project in projects: + + project_id = project.get("id", "UNKNOWN") + + for field in REQUIRED_FIELDS: + + value = project.get(field) + + if value is None: + continue + + if isinstance(value, str) and not value.strip(): + errors.append( + f"Project {project_id} has an empty '{field}' field." + ) + + elif isinstance(value, list) and not value: + errors.append( + f"Project {project_id} has an empty '{field}' field." + ) + + return errors + + +def _validate_starter_code( + projects: list[dict[str, Any]], + repository_root: Path, +) -> list[str]: + """ + Verify starter code files exist. + """ + + warnings: list[str] = [] + + for project in projects: + + project_id = project.get("id", "UNKNOWN") + + starter_code = project.get("starter_code") + + if not starter_code: + continue + + starter_path = repository_root / starter_code + + if not starter_path.is_file(): + warnings.append( + ( + f"[{project_id}] {starter_code}" + ) + ) + + return warnings + + +def run( + dataset_path: Path | None = None, +) -> ValidationResult: + """ + Execute all dataset validation checks. + """ + + if dataset_path is None: + dataset_path = DATASET_PATH + + repository_root = dataset_path.parent.parent.resolve() + + + result = ValidationResult( + name="Dataset Validator", + passed=True, + ) + + try: + projects = _load_projects(dataset_path) + + except FileNotFoundError: + + result.passed = False + result.errors.append( + f"Dataset not found: {dataset_path}" + ) + return result + + except json.JSONDecodeError as exc: + + result.passed = False + result.errors.append( + f"Invalid JSON: {exc}" + ) + return result + + duplicate_ids = _validate_duplicate_ids(projects) + duplicate_titles = _validate_duplicate_titles(projects) + missing_fields = _validate_required_fields(projects) + empty_fields = _validate_empty_fields(projects) + missing_files = _validate_starter_code( + projects, + repository_root, + ) + + result.details = { + "projects": len(projects), + "checks": { + "duplicate_ids": duplicate_ids, + "duplicate_titles": duplicate_titles, + "missing_fields": missing_fields, + "empty_fields": empty_fields, + "missing_files": missing_files, + }, + } + + result.errors.extend(duplicate_ids) + result.errors.extend(duplicate_titles) + result.errors.extend(missing_fields) + result.errors.extend(empty_fields) + + # Starter code issues are warnings + result.warnings.extend(missing_files) + + result.passed = not result.errors + + return result \ No newline at end of file