diff --git a/CHANGELOG.md b/CHANGELOG.md index cd102326..bc3da18a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,10 +10,13 @@ (e.g. `js`, `reactjs`, `ts`, `node`, `py`) to their canonical lowercase names (#1116) - Added 5 new unit tests in `tests/test_basic.py` to verify synonym normalization end-to-end (#1116) - 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 +- Dataset Validator to detect duplicate project IDs, duplicate project titles, missing required fields, empty required fields, and missing starter code references +- Starter Code Integrity Validator to detect orphan starter code files, empty starter code files, hidden files, and unsupported file types ### Changed +- DevPath Sentinel now executes all available validators through a unified CLI with consolidated validation reporting +- Updated Sentinel documentation to include the Starter Code Integrity Validator and multi-validator workflow - Contributors are now expected to document user-facing changes in CHANGELOG.md - `parse_skills()` now normalizes skill abbreviations via `SKILL_SYNONYMS` before scoring, so inputs like "JS, ReactJS, Node" correctly match projects tagged "JavaScript, React, Node.js" (#1116) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index fe9e2b5d..2a871024 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -255,7 +255,7 @@ pip install pytest pytest tests/ -v ``` -If you add a new feature, add at least one corresponding test in `tests/test_basic.py`. +If you add a new feature, add corresponding unit tests covering the new functionality. ### Repository Validation @@ -263,6 +263,14 @@ Before opening a pull request, contributors are encouraged to run DevPath Sentin ```bash python -m tools.sentinel.cli +``` + +The Sentinel CLI executes all available validators, including: + +- Dataset Validator +- Starter Code Integrity Validator + +Review any reported warnings or errors before submitting your pull request. --- @@ -271,7 +279,7 @@ python -m tools.sentinel.cli ### 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 +- Run `python -m tools.sentinel.cli` to validate the project dataset and starter code repository 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 cb5f95cf..5824d604 100644 --- a/README.md +++ b/README.md @@ -189,21 +189,34 @@ All tests passed --- -### Validate the Project Dataset +### Validate Repository Integrity -Run DevPath Sentinel to check the repository dataset for common integrity issues: +Run DevPath Sentinel to validate both the project dataset and the repository's starter code. ```bash python -m tools.sentinel.cli ``` -The current validator checks for: +DevPath Sentinel executes the following validators: -- duplicate project IDs -- duplicate project titles -- missing required fields -- empty required fields -- missing starter code references +#### Dataset Validator + +Checks for: + +- Duplicate project IDs +- Duplicate project titles +- Missing required fields +- Empty required fields +- Missing starter code references + +#### Starter Code Integrity Validator + +Checks for: + +- Orphan starter code files +- Empty starter code files +- Unsupported starter code file types +- Hidden files inside the `starter_code/` directory --- diff --git a/tests/test_starter_code_validator.py b/tests/test_starter_code_validator.py new file mode 100644 index 00000000..ebe919d6 --- /dev/null +++ b/tests/test_starter_code_validator.py @@ -0,0 +1,367 @@ +""" +Tests for the DevPath Sentinel Starter Code Integrity Validator. +""" + +from __future__ import annotations + +import json + +from tools.sentinel.validators.starter_code_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, + *, + content="# starter code", +): + """Create a starter code file.""" + + starter_dir = tmp_path / "starter_code" + starter_dir.mkdir(exist_ok=True) + + file_path = starter_dir / filename + + file_path.write_text( + content, + encoding="utf-8", + ) + + return file_path + + +def test_valid_starter_code(tmp_path): + """A valid starter code directory should pass validation.""" + + create_starter_file( + tmp_path, + "expense_tracker.py", + ) + + dataset = write_dataset( + tmp_path, + [create_project()], + ) + + result = run( + dataset_path=dataset, + starter_code_dir=tmp_path / "starter_code", + ) + + assert result.name == "Starter Code Integrity Validator" + + assert result.passed is True + assert result.errors == [] + assert result.warnings == [] + + assert result.details["resource"] == "Starter Code Files" + assert result.details["count"] == 1 + + checks = result.details["checks"] + + assert checks["orphan_files"] == [] + assert checks["empty_files"] == [] + assert checks["hidden_files"] == [] + assert checks["unsupported_extensions"] == [] + + metadata = result.details["metadata"] + + assert metadata["orphan_files"]["severity"] == "error" + assert metadata["empty_files"]["severity"] == "error" + assert metadata["hidden_files"]["severity"] == "warning" + assert metadata["unsupported_extensions"]["severity"] == "warning" + + +def test_orphan_files(tmp_path): + """Orphan starter code files should produce an error.""" + + create_starter_file( + tmp_path, + "expense_tracker.py", + ) + + create_starter_file( + tmp_path, + "calculator.py", + ) + + dataset = write_dataset( + tmp_path, + [create_project()], + ) + + result = run( + dataset_path=dataset, + starter_code_dir=tmp_path / "starter_code", + ) + + assert result.passed is False + + assert any( + "Orphan Files" + in error + for error in result.errors + ) + + assert result.details["checks"]["orphan_files"] == [ + "starter_code/calculator.py", + ] + + +def test_empty_files(tmp_path): + """Empty starter code files should produce an error.""" + + create_starter_file( + tmp_path, + "expense_tracker.py", + content="", + ) + + dataset = write_dataset( + tmp_path, + [create_project()], + ) + + result = run( + dataset_path=dataset, + starter_code_dir=tmp_path / "starter_code", + ) + + assert result.passed is False + + assert any( + "Empty Files" + in error + for error in result.errors + ) + + assert result.details["checks"]["empty_files"] == [ + "starter_code/expense_tracker.py", + ] + + +def test_hidden_files(tmp_path): + """Hidden starter code files should produce a warning.""" + + create_starter_file( + tmp_path, + "expense_tracker.py", + ) + + create_starter_file( + tmp_path, + ".gitkeep", + ) + + dataset = write_dataset( + tmp_path, + [create_project()], + ) + + result = run( + dataset_path=dataset, + starter_code_dir=tmp_path / "starter_code", + ) + + assert result.passed is False + + assert any( + "Orphan Files" + in error + for error in result.errors + ) + + assert len(result.warnings) == 2 + + assert any( + "Hidden Files" + in warning + for warning in result.warnings + ) + + assert any( + "Unsupported Extensions" + in warning + for warning in result.warnings + ) + + assert any( + "Hidden Files" + in warning + for warning in result.warnings + ) + + assert result.details["checks"]["hidden_files"] == [ + "starter_code/.gitkeep", + ] + + print(result.errors) + print(result.warnings) + print(result.details) + + +def test_unsupported_extensions(tmp_path): + """Unsupported starter code files should produce a warning.""" + + create_starter_file( + tmp_path, + "expense_tracker.py", + ) + + create_starter_file( + tmp_path, + "notes.pdf", + ) + + dataset = write_dataset( + tmp_path, + [create_project()], + ) + + result = run( + dataset_path=dataset, + starter_code_dir=tmp_path / "starter_code", + ) + + assert result.passed is False + + assert any( + "Orphan Files" + in error + for error in result.errors + ) + + assert len(result.warnings) == 1 + + assert any( + "Unsupported Extensions" + in warning + for warning in result.warnings + ) + + assert any( + "Unsupported Extensions" + in warning + for warning in result.warnings + ) + + assert result.details["checks"]["unsupported_extensions"] == [ + "starter_code/notes.pdf", + ] + + +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_path=dataset, + starter_code_dir=tmp_path / "starter_code", + ) + + 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_path=dataset, + starter_code_dir=tmp_path / "starter_code", + ) + + assert result.passed is False + + assert any( + "Dataset file not found" + in error + for error in result.errors + ) + + +def test_missing_starter_code_directory(tmp_path): + """Missing starter code directory should fail validation.""" + + dataset = write_dataset( + tmp_path, + [create_project()], + ) + + result = run( + dataset_path=dataset, + starter_code_dir=tmp_path / "starter_code", + ) + + assert result.passed is False + + assert any( + "Starter code directory not found" + in error + for error in result.errors + ) \ No newline at end of file diff --git a/tools/sentinel/README.md b/tools/sentinel/README.md index c85eae99..dd0b8bc3 100644 --- a/tools/sentinel/README.md +++ b/tools/sentinel/README.md @@ -2,11 +2,15 @@ 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. +It provides modular validators that help contributors identify repository and dataset issues before submitting changes. ## Features -The current validator detects: +DevPath Sentinel currently includes the following validators. + +### Dataset Validator + +Detects: - Duplicate project IDs - Duplicate project titles @@ -14,6 +18,15 @@ The current validator detects: - Empty required fields - Missing starter code references +### Starter Code Integrity Validator + +Detects: + +- Orphan starter code files +- Empty starter code files +- Unsupported starter code file types +- Hidden files inside the `starter_code/` directory + ## Usage Run the validator from the project root: @@ -22,16 +35,27 @@ Run the validator from the project root: python -m tools.sentinel.cli ``` -The validator prints a summary of all checks, including any warnings or errors found in the dataset. +The CLI executes all available validators sequentially and prints a consolidated validation report, including any warnings or errors detected. -## Project Structure +Example output: +```text +DevPath Sentinel + +Running Dataset Validator... + +Running Starter Code Integrity Validator... ``` + +## Project Structure + +```text tools/ └── sentinel/ ├── cli.py ├── models.py ├── report.py └── validators/ - └── dataset_validator.py + ├── dataset_validator.py + └── starter_code_validator.py ``` \ No newline at end of file diff --git a/tools/sentinel/cli.py b/tools/sentinel/cli.py index ac85350b..61a1e73a 100644 --- a/tools/sentinel/cli.py +++ b/tools/sentinel/cli.py @@ -1,10 +1,13 @@ """ Command-line entry point for DevPath Sentinel. """ + +from __future__ import annotations + import sys from .report import print_banner, print_validation_result -from .validators.dataset_validator import run +from .validators import dataset_validator, starter_code_validator def main() -> None: @@ -12,15 +15,23 @@ def main() -> None: print_banner() - result = run() + validators = [ + dataset_validator.run, + starter_code_validator.run, + ] + + has_errors = False + + for validator in validators: + result = validator() - print_validation_result(result) + print_validation_result(result) - if result.errors: - sys.exit(1) + if result.errors: + has_errors = True - sys.exit(0) + sys.exit(1 if has_errors else 0) if __name__ == "__main__": - main() \ No newline at end of file + main() diff --git a/tools/sentinel/report.py b/tools/sentinel/report.py index 11d03f4d..3402f6eb 100644 --- a/tools/sentinel/report.py +++ b/tools/sentinel/report.py @@ -1,85 +1,104 @@ """ -Utilities for printing formatted Sentinel reports. +Console report utilities for DevPath Sentinel. """ +from __future__ import annotations + from tools.sentinel.models import ValidationResult -ERROR_CHECKS = { - "duplicate_ids", - "duplicate_titles", - "missing_fields", - "empty_fields", +_STATUS = { + True: "PASS", + False: "FAIL", } -WARNING_CHECKS = { - "missing_files", +_SEVERITY_ICON = { + "error": "✗", + "warning": "!", } def print_banner() -> None: - """Print the Sentinel startup banner.""" + """Print the DevPath Sentinel 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 a formatted validation report. + """ - print(f"Running {result.name}...\n") + _print_header(result) + _print_resource_summary(result) + _print_checks(result) + _print_messages(result) - print(f"Projects scanned : {result.details.get('projects', 0)}") - print() - checks = result.details.get("checks", {}) +def _print_header(result: ValidationResult) -> None: + """ + Print report header. + """ - check_order = ( - ("Duplicate IDs", "duplicate_ids"), - ("Duplicate Titles", "duplicate_titles"), - ("Required Fields", "missing_fields"), - ("Empty Fields", "empty_fields"), - ("Starter Code", "missing_files"), - ) + print(f"\n=== {result.name} ===") + print(f"Status : {_STATUS[result.passed]}") - passed_checks = 0 - for label, key in check_order: +def _print_resource_summary(result: ValidationResult) -> None: + """ + Print resource summary. + """ - issues = checks.get(key, []) + details = result.details - if not issues: - print(f"✓ {label:.<28} PASS") - passed_checks += 1 - continue + resource = details.get("resource") + count = details.get("count") - if key in WARNING_CHECKS: - print(f"⚠ {label:.<28} WARN ({len(issues)})") - else: - print(f"✗ {label:.<28} FAIL ({len(issues)})") + if resource is not None and count is not None: + print(f"{resource}: {count}") - for issue in issues: - print(f" • {issue}") - print() +def _print_checks(result: ValidationResult) -> None: + """ + Print validation checks. + """ - print("=" * 60) + details = result.details + + checks = details.get("checks", {}) + metadata = details.get("metadata", {}) + + if not checks: + return + + print("\nChecks") + + for check_name, issues in checks.items(): + info = metadata.get(check_name, {}) - 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)}") + label = info.get("label", check_name.replace("_", " ").title()) + severity = info.get("severity", "error") - print() + icon = _SEVERITY_ICON.get(severity, "-") + + print(f" {icon} {label:<24} {len(issues)} issue(s)") + + +def _print_messages(result: ValidationResult) -> None: + """ + Print detailed errors and warnings. + """ if result.errors: - print("Status : FAILED") - elif result.warnings: - print("Status : PASSED WITH WARNINGS") - else: - print("Status : PASSED") + print("\nErrors") + + for message in result.errors: + print(f" - {message}") + + if result.warnings: + print("\nWarnings") - print("=" * 60) \ No newline at end of file + for message in result.warnings: + print(f" - {message}") diff --git a/tools/sentinel/validators/__init__.py b/tools/sentinel/validators/__init__.py index 7800887f..bc9dc932 100644 --- a/tools/sentinel/validators/__init__.py +++ b/tools/sentinel/validators/__init__.py @@ -2,4 +2,4 @@ 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 index f0a6aff9..87116ce1 100644 --- a/tools/sentinel/validators/dataset_validator.py +++ b/tools/sentinel/validators/dataset_validator.py @@ -47,21 +47,13 @@ def _validate_duplicate_ids( Find duplicate project IDs. """ - ids = [ - project.get("id") - for project in projects - ] + ids = [project.get("id") for project in projects] duplicates = [ - str(project_id) - for project_id, count in Counter(ids).items() - if count > 1 + 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) - ] + return [f"Duplicate project ID: {project_id}" for project_id in sorted(duplicates)] def _validate_duplicate_titles( @@ -71,21 +63,11 @@ def _validate_duplicate_titles( Find duplicate project titles. """ - titles = [ - project.get("title", "").strip() - for project in projects - ] + titles = [project.get("title", "").strip() for project in projects] - duplicates = [ - title - for title, count in Counter(titles).items() - if count > 1 - ] + duplicates = [title for title, count in Counter(titles).items() if count > 1] - return [ - f'Duplicate project title: "{title}"' - for title in sorted(duplicates) - ] + return [f'Duplicate project title: "{title}"' for title in sorted(duplicates)] def _validate_required_fields( @@ -98,17 +80,11 @@ def _validate_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 - ] + missing_fields = [field for field in REQUIRED_FIELDS if field not in project] if missing_fields: - errors.append( ( f"Project {project_id} " @@ -119,6 +95,7 @@ def _validate_required_fields( return errors + def _validate_empty_fields( projects: list[dict[str, Any]], ) -> list[str]: @@ -129,25 +106,19 @@ def _validate_empty_fields( 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." - ) + 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." - ) + errors.append(f"Project {project_id} has an empty '{field}' field.") return errors @@ -163,7 +134,6 @@ def _validate_starter_code( warnings: list[str] = [] for project in projects: - project_id = project.get("id", "UNKNOWN") starter_code = project.get("starter_code") @@ -174,11 +144,7 @@ def _validate_starter_code( starter_path = repository_root / starter_code if not starter_path.is_file(): - warnings.append( - ( - f"[{project_id}] {starter_code}" - ) - ) + warnings.append((f"[{project_id}] {starter_code}")) return warnings @@ -195,7 +161,6 @@ def run( repository_root = dataset_path.parent.parent.resolve() - result = ValidationResult( name="Dataset Validator", passed=True, @@ -205,19 +170,13 @@ def run( projects = _load_projects(dataset_path) except FileNotFoundError: - result.passed = False - result.errors.append( - f"Dataset not found: {dataset_path}" - ) + 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}" - ) + result.errors.append(f"Invalid JSON: {exc}") return result duplicate_ids = _validate_duplicate_ids(projects) @@ -230,7 +189,8 @@ def run( ) result.details = { - "projects": len(projects), + "resource": "Projects", + "count": len(projects), "checks": { "duplicate_ids": duplicate_ids, "duplicate_titles": duplicate_titles, @@ -238,6 +198,28 @@ def run( "empty_fields": empty_fields, "missing_files": missing_files, }, + "metadata": { + "duplicate_ids": { + "label": "Duplicate IDs", + "severity": "error", + }, + "duplicate_titles": { + "label": "Duplicate Titles", + "severity": "error", + }, + "missing_fields": { + "label": "Required Fields", + "severity": "error", + }, + "empty_fields": { + "label": "Empty Fields", + "severity": "error", + }, + "missing_files": { + "label": "Starter Code", + "severity": "warning", + }, + }, } result.errors.extend(duplicate_ids) @@ -250,4 +232,4 @@ def run( result.passed = not result.errors - return result \ No newline at end of file + return result diff --git a/tools/sentinel/validators/starter_code_validator.py b/tools/sentinel/validators/starter_code_validator.py new file mode 100644 index 00000000..be0fcdc8 --- /dev/null +++ b/tools/sentinel/validators/starter_code_validator.py @@ -0,0 +1,336 @@ +""" +Starter Code Integrity Validator + +This validator inspects the repository's ``starter_code/`` directory and +checks its overall integrity. + +Responsibilities: +- Detect orphan starter code files. +- Detect empty starter code files. +- Detect unsupported file types. +- Detect hidden files. + +The validator returns a ValidationResult and performs no console output. +""" + +from __future__ import annotations + +import json +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" +STARTER_CODE_DIR = REPO_ROOT / "starter_code" + +ALLOWED_STARTER_CODE_EXTENSIONS = { + ".py", + ".js", + ".java", + ".html", + ".css", + ".yml", + ".yaml", + ".txt", + ".md", +} + + +def _load_projects(dataset_path: Path) -> list[dict[str, Any]]: + """ + Load the project dataset. + + Args: + dataset_path: Path to the projects.json dataset. + + Returns: + A list of project dictionaries. + + Raises: + FileNotFoundError: + If the dataset file does not exist. + + ValueError: + If the dataset is not a valid JSON array. + """ + if not dataset_path.is_file(): + raise FileNotFoundError(f"Dataset file not found: {dataset_path}") + + try: + with dataset_path.open("r", encoding="utf-8") as file: + projects = json.load(file) + except json.JSONDecodeError as exc: + raise ValueError(f"Invalid JSON in dataset: {dataset_path}") from exc + + if not isinstance(projects, list): + raise ValueError("Dataset must contain a JSON array of projects.") + + return projects + + +def _collect_referenced_files( + projects: list[dict[str, Any]], +) -> set[Path]: + """ + Collect all starter code files referenced by the dataset. + + Args: + projects: List of project dictionaries loaded from the dataset. + + Returns: + A set of repository-relative starter code paths. + """ + referenced_files: set[Path] = set() + + for project in projects: + starter_code = project.get("starter_code") + + if not isinstance(starter_code, str): + continue + + starter_code = starter_code.strip() + + if not starter_code: + continue + + referenced_files.add(Path(starter_code)) + + return referenced_files + + +def _collect_repository_files( + starter_code_dir: Path, +) -> list[Path]: + """ + Collect every file inside the starter_code directory. + + Args: + starter_code_dir: Path to the repository's starter_code directory. + + Returns: + A list of repository-relative starter code file paths. + + Raises: + FileNotFoundError: + If the starter_code directory does not exist. + + NotADirectoryError: + If the provided path is not a directory. + """ + if not starter_code_dir.exists(): + raise FileNotFoundError(f"Starter code directory not found: {starter_code_dir}") + + if not starter_code_dir.is_dir(): + raise NotADirectoryError(f"Expected a directory: {starter_code_dir}") + + repository_files: list[Path] = [] + + for path in sorted(starter_code_dir.rglob("*")): + if path.is_file(): + repository_files.append( + Path("starter_code") / path.relative_to(starter_code_dir) + ) + + return repository_files + + +def _validate_orphan_files( + repository_files: list[Path], + referenced_files: set[Path], +) -> list[str]: + """ + Detect starter code files that are not referenced by the dataset. + + Args: + repository_files: Repository-relative paths of all files present + in the starter_code directory. + referenced_files: Repository-relative paths referenced by the + project dataset. + + Returns: + A sorted list of orphan starter code file paths. + """ + orphan_files = [ + str(file_path) + for file_path in repository_files + if file_path not in referenced_files + ] + + return sorted(orphan_files) + + +def _validate_empty_files( + repository_files: list[Path], + starter_code_dir: Path, +) -> list[str]: + """ + Detect empty starter code files. + + Args: + repository_files: Repository-relative paths of all files present + in the starter_code directory. + + Returns: + A sorted list of empty starter code file paths. + """ + empty_files: list[str] = [] + + for file_path in repository_files: + absolute_path = starter_code_dir / file_path.relative_to("starter_code") + + if absolute_path.stat().st_size == 0: + empty_files.append(str(file_path)) + + return sorted(empty_files) + + +def _validate_hidden_files( + repository_files: list[Path], +) -> list[str]: + """ + Detect hidden files inside the starter_code directory. + + Args: + repository_files: Repository-relative paths of all files present + in the starter_code directory. + + Returns: + A sorted list of hidden starter code file paths. + """ + hidden_files = [ + str(file_path) + for file_path in repository_files + if file_path.name.startswith(".") + ] + + return sorted(hidden_files) + + +def _validate_supported_extensions( + repository_files: list[Path], +) -> list[str]: + """ + Detect starter code files with unsupported file extensions. + + Args: + repository_files: Repository-relative paths of all files present + in the starter_code directory. + + Returns: + A sorted list of starter code file paths that use unsupported + file extensions. + """ + unsupported_files = [ + str(file_path) + for file_path in repository_files + if file_path.suffix.lower() not in ALLOWED_STARTER_CODE_EXTENSIONS + ] + + return sorted(unsupported_files) + + +def run( + dataset_path: Path | None = None, + starter_code_dir: Path | None = None, +) -> ValidationResult: + """ + Execute the Starter Code Integrity Validator. + + Args: + dataset_path: Optional path to the projects dataset. + starter_code_dir: Optional path to the starter_code directory. + + Returns: + ValidationResult describing the validation outcome. + """ + dataset_path = dataset_path or DATASET_PATH + starter_code_dir = starter_code_dir or STARTER_CODE_DIR + + try: + projects = _load_projects(dataset_path) + + referenced_files = _collect_referenced_files(projects) + repository_files = _collect_repository_files(starter_code_dir) + + orphan_files = _validate_orphan_files( + repository_files, + referenced_files, + ) + empty_files = _validate_empty_files( + repository_files, + starter_code_dir, + ) + hidden_files = _validate_hidden_files(repository_files) + unsupported_files = _validate_supported_extensions( + repository_files, + ) + + except ( + FileNotFoundError, + NotADirectoryError, + ValueError, + ) as exc: + return ValidationResult( + name="Starter Code Integrity Validator", + passed=False, + errors=[str(exc)], + warnings=[], + details={}, + ) + + errors: list[str] = [] + + if orphan_files: + errors.append(f"Orphan Files: {len(orphan_files)} issue(s) detected.") + + if empty_files: + errors.append(f"Empty Files: {len(empty_files)} issue(s) detected.") + + warnings: list[str] = [] + + if hidden_files: + warnings.append(f"Hidden Files: {len(hidden_files)} issue(s) detected.") + + if unsupported_files: + warnings.append( + f"Unsupported Extensions: {len(unsupported_files)} issue(s) detected." + ) + + passed = not errors + + return ValidationResult( + name="Starter Code Integrity Validator", + passed=passed, + errors=errors, + warnings=warnings, + details={ + "resource": "Starter Code Files", + "count": len(repository_files), + "checks": { + "orphan_files": orphan_files, + "empty_files": empty_files, + "hidden_files": hidden_files, + "unsupported_extensions": unsupported_files, + }, + "metadata": { + "orphan_files": { + "label": "Orphan Files", + "severity": "error", + }, + "empty_files": { + "label": "Empty Files", + "severity": "error", + }, + "hidden_files": { + "label": "Hidden Files", + "severity": "warning", + }, + "unsupported_extensions": { + "label": "Unsupported Extensions", + "severity": "warning", + }, + }, + }, + )