diff --git a/.github/workflows/trigger-site-rebuild.yml b/.github/workflows/trigger-site-rebuild.yml index 185a45c..c922abb 100644 --- a/.github/workflows/trigger-site-rebuild.yml +++ b/.github/workflows/trigger-site-rebuild.yml @@ -16,6 +16,7 @@ on: - '.github/workflows/validate-examples.yml' - '.github/workflows/trigger-site-rebuild.yml' - 'validate_examples.py' + - 'test_validate_examples.py' - 'build_examples.py' - 'download_bob.py' - 'create_archives.py' @@ -27,6 +28,10 @@ jobs: steps: [ { name: 'Checkout', uses: actions/checkout@v4, with: { fetch-depth: 0 } }, { name: 'Install Python', uses: actions/setup-python@v4, with: { python-version: 3.10.5, architecture: x64 } }, + { + name: 'Test example validation', + run: 'python -m unittest test_validate_examples.py' + }, { name: 'Validate examples', env: { diff --git a/.github/workflows/validate-examples.yml b/.github/workflows/validate-examples.yml index 5c2ae4b..a434e0c 100644 --- a/.github/workflows/validate-examples.yml +++ b/.github/workflows/validate-examples.yml @@ -7,6 +7,7 @@ on: - '.github/workflows/validate-examples.yml' - '.github/workflows/trigger-site-rebuild.yml' - 'validate_examples.py' + - 'test_validate_examples.py' - 'build_examples.py' - 'download_bob.py' @@ -16,6 +17,10 @@ jobs: steps: [ { name: 'Checkout', uses: actions/checkout@v4, with: { fetch-depth: 0 } }, { name: 'Install Python', uses: actions/setup-python@v4, with: { python-version: 3.10.5, architecture: x64 } }, + { + name: 'Test example validation', + run: 'python -m unittest test_validate_examples.py' + }, { name: 'Validate examples', run: 'python validate_examples.py --changed-from "${{ github.event.pull_request.base.sha }}" --changed-to "${{ github.event.pull_request.head.sha }}"' diff --git a/README.md b/README.md index 2e96d41..fb57982 100644 --- a/README.md +++ b/README.md @@ -23,5 +23,5 @@ thumbnail: myimage.png * Use the `authors` array for one or more contributors. The older single `author` field is still supported for existing examples. * Examples use the repository-wide CC0-1.0 licence by default. Add a `license` field only when an example needs a different licence. -* List any scripts your example uses in the `scripts` field of the file header +* List any scripts your example uses in the `scripts` field of the file header. A file name is enough when it is unique in the project. If multiple scripts have the same file name, use the exact path relative to the example project root (for example, `main/player/player.script`). * The thumbnail image will be used on https://defold.com/examples diff --git a/material/wave_background/example.md b/material/wave_background/example.md index aabb263..25a9974 100644 --- a/material/wave_background/example.md +++ b/material/wave_background/example.md @@ -5,7 +5,7 @@ brief: Shows how to use a Time shader constant to achieve a moving wave effect authors: - JuLongZhiLu(巨龙之路) - Brian Kramer -scripts: wave_background.fp +scripts: example/wave_background.fp thumbnail: thumbnail.png --- diff --git a/test_validate_examples.py b/test_validate_examples.py new file mode 100644 index 0000000..31c6622 --- /dev/null +++ b/test_validate_examples.py @@ -0,0 +1,76 @@ +import tempfile +import unittest +from pathlib import Path + +from validate_examples import example_scripts, resolve_script_reference + + +class ExampleScriptResolutionTests(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + self.project_dir = Path(self.temp_dir.name) + + def tearDown(self): + self.temp_dir.cleanup() + + def add_script(self, relative_path: str) -> None: + path = self.project_dir / relative_path + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text("function init(self) end\n", encoding="utf-8") + + def test_unique_filename_is_found_anywhere_in_project(self): + self.add_script("main/scroll_manager/scroll_item.script") + available = example_scripts(self.project_dir) + + self.assertEqual( + resolve_script_reference("scroll_item.script", available), + "main/scroll_manager/scroll_item.script", + ) + + def test_exact_project_relative_path_is_supported(self): + self.add_script("main/scroll_manager/scroll_item.script") + available = example_scripts(self.project_dir) + + self.assertEqual( + resolve_script_reference("main/scroll_manager/scroll_item.script", available), + "main/scroll_manager/scroll_item.script", + ) + with self.assertRaisesRegex(ValueError, "does not exist"): + resolve_script_reference("scroll_manager/scroll_item.script", available) + + def test_ambiguous_filename_lists_exact_paths(self): + self.add_script("main/first/controller.script") + self.add_script("main/second/controller.script") + available = example_scripts(self.project_dir) + + with self.assertRaisesRegex( + ValueError, + "main/first/controller.script, main/second/controller.script", + ): + resolve_script_reference("controller.script", available) + self.assertEqual( + resolve_script_reference("main/second/controller.script", available), + "main/second/controller.script", + ) + + def test_generated_and_dependency_directories_are_ignored(self): + self.add_script("main/controller.script") + self.add_script("build/controller.script") + self.add_script(".internal/lib/controller.script") + self.add_script("node_modules/package/controller.script") + + self.assertEqual(example_scripts(self.project_dir), ["main/controller.script"]) + + def test_unsafe_or_non_normalized_paths_are_rejected(self): + for script in ( + "/main/controller.script", + "./main/controller.script", + "main/../controller.script", + "main\\controller.script", + ): + with self.subTest(script=script), self.assertRaisesRegex(ValueError, "normalized"): + resolve_script_reference(script, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/validate_examples.py b/validate_examples.py index 32bbbf1..6b708f7 100644 --- a/validate_examples.py +++ b/validate_examples.py @@ -6,7 +6,7 @@ import os import subprocess import sys -from pathlib import Path +from pathlib import Path, PurePosixPath SCRIPT_EXTENSIONS = { @@ -20,6 +20,16 @@ ".render_script", } +IGNORED_SCRIPT_DIRS = { + ".deps", + ".git", + ".internal", + "build", + "builtins", + "js-web", + "node_modules", +} + def tracked_example_dirs() -> list[Path]: try: @@ -90,17 +100,48 @@ def split_scripts(value: str | None) -> list[str]: return [script.strip().strip("\"'") for script in value.split(",") if script.strip()] -def example_scripts(example_dir: Path) -> set[str]: - scripts: set[str] = set() - source_dir = example_dir / "example" - if not source_dir.exists(): +def example_scripts(example_dir: Path) -> list[str]: + scripts: list[str] = [] + if not example_dir.exists(): return scripts - for path in source_dir.rglob("*"): - if path.is_file() and path.suffix in SCRIPT_EXTENSIONS: - scripts.add(path.name) + for root, dirnames, filenames in os.walk(example_dir): + dirnames[:] = [dirname for dirname in dirnames if dirname not in IGNORED_SCRIPT_DIRS] + root_path = Path(root) + for filename in filenames: + path = root_path / filename + if path.suffix in SCRIPT_EXTENSIONS: + scripts.append(path.relative_to(example_dir).as_posix()) + + return sorted(scripts) + + +def resolve_script_reference(script: str, available_scripts: list[str]) -> str: + path = PurePosixPath(script) + if ( + not script + or "\\" in script + or path.is_absolute() + or str(path) != script + or any(part in {"", ".", ".."} for part in path.parts) + ): + raise ValueError( + f"scripts entry must be a file name or normalized project-relative path, got '{script}'" + ) - return scripts + if len(path.parts) > 1: + if script not in available_scripts: + raise ValueError(f"scripts entry '{script}' does not exist in the project") + return script + + matches = [candidate for candidate in available_scripts if PurePosixPath(candidate).name == script] + if not matches: + raise ValueError(f"scripts entry '{script}' does not exist in the project") + if len(matches) > 1: + raise ValueError( + f"scripts entry '{script}' is ambiguous; use an exact project-relative path: {', '.join(matches)}" + ) + return matches[0] def parse_args() -> argparse.Namespace: @@ -130,10 +171,10 @@ def validate() -> int: available_scripts = example_scripts(example_dir) for script in split_scripts(frontmatter_value(markdown_file, "scripts")): - if script != os.path.basename(script): - errors.append(f"{markdown_file}: scripts entry must be a file name, got '{script}'") - elif script not in available_scripts: - errors.append(f"{markdown_file}: scripts entry '{script}' does not exist in {example_dir / 'example'}") + try: + resolve_script_reference(script, available_scripts) + except ValueError as error: + errors.append(f"{markdown_file}: {error}") if errors: print("Example validation failed:")