/
yrostov
/
project-process
Обзор
Документация
Войти
/
yrostov
/
project-process
Код
Пакеты
0
Релизы
0
Аналитика
Безопасность
main
tests/test_project_process.py
570 строк
24 KB
urostov
fix: pin checkout action to full SHA
22 июл 2026, 10:54
22 июл 2026, 10:54
c4c7b73
Код
Авторство
О чём код?
from __future__ import annotations import contextlib import importlib.util import io import json import shutil import subprocess import sys import tempfile import unittest from pathlib import Path try: import jsonschema except ImportError: # Runtime CLI intentionally has no third-party dependencies. jsonschema = None MODULE_PATH = Path(__file__).parents[1] / "scripts" / "project_process.py" SPEC = importlib.util.spec_from_file_location("project_process", MODULE_PATH) assert SPEC and SPEC.loader pp = importlib.util.module_from_spec(SPEC) sys.modules[SPEC.name] = pp SPEC.loader.exec_module(pp) def git(root: Path, *args: str) -> str: completed = subprocess.run( ["git", "-C", str(root), *args], check=True, capture_output=True, text=True, ) return completed.stdout.strip() def write_json(path: Path, value: dict) -> None: path.parent.mkdir(parents=True, exist_ok=True) path.write_text(json.dumps(value, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") class RepositoryFixture: def __init__(self, *, executed_tests: int = 1, fake_junit: bool = False, mutate_tracked: bool = False) -> None: self.temp = tempfile.TemporaryDirectory() self.root = Path(self.temp.name) git(self.root, "init", "-b", "main") git(self.root, "config", "user.email", "tests@example.invalid") git(self.root, "config", "user.name", "Process Tests") for path, content in { "AGENTS.md": "# Agent rules\n", "docs/status.md": "# Status\nInitial state.\n", "docs/llm-process.md": "# Process\nStrict mode.\n", "docs/questions.md": "# Questions\nNone.\n", "CHANGELOG.md": "# Changelog\nInitial.\n", "src/app.py": "VALUE = 1\n", }.items(): target = self.root / path target.parent.mkdir(parents=True, exist_ok=True) target.write_text(content, encoding="utf-8") report = '<testsuite tests="1" failures="0" errors="0" skipped="0"></testsuite>' if fake_junit else ( f'<testsuite tests="{executed_tests}" failures="0" errors="0" skipped="0">' + "".join(f'<testcase name="t{i}" />' for i in range(executed_tests)) + "</testsuite>" ) check_code = "from pathlib import Path; import sys; Path(sys.argv[1]).write_text(sys.argv[2])" if mutate_tracked: check_code += "; Path('src/app.py').write_text('VALUE = 999\\n')" command = [ sys.executable, "-c", check_code, "{evidence_dir}/tests.xml", report, ] self.config = { "schema_version": 1, "required_documents": [ "AGENTS.md", "docs/status.md", "docs/llm-process.md", "docs/questions.md" ], "requirement_directories": ["docs/requirements"], "task_directories": ["docs/tasks", "docs/tasks-archive"], "planning_paths": [ "docs/requirements/**", "docs/tasks/**", "docs/tasks-archive/**", "docs/status.md", "docs/questions.md", ], "protected_paths": [ ".process/**", ".github/workflows/project-process.yml", "scripts/project_process.py" ], "ignored_paths": [".git/**"], "impact_rules": [ { "paths": ["src/**"], "require_changed_documents": ["docs/status.md", "CHANGELOG.md"], }, { "paths": ["docs/**", "CHANGELOG.md", "AGENTS.md"], "require_changed_documents": [], }, ], "checks": [ { "id": "CHECK-TESTS", "command": command, "result": { "format": "junit", "path": "tests.xml", "minimum_executed": 1, }, } ], } self.requirement = { "schema_version": 1, "id": "REQ-APP-VALUE", "title": "Application value", "status": "active", "statement": "The application exposes the intended value.", } self.task = { "schema_version": 1, "id": "PROJ-1", "title": "Change application value", "type": "task", "priority": "medium", "status": "in_progress", "source_of_truth": ["docs/requirements/REQ-APP-VALUE.json"], "goal": "Change the application value safely.", "out_of_scope": ["Unrelated behavior."], "affected_paths": ["src/**", "docs/**", "CHANGELOG.md"], "requirement_ids": ["REQ-APP-VALUE"], "acceptance_criteria": [ { "id": "AC-PROJ-1-01", "text": "Registered tests pass.", "verification_ids": ["VERIFY-PROJ-1-01"], } ], "verifications": [ { "id": "VERIFY-PROJ-1-01", "kind": "automated", "check_id": "CHECK-TESTS", } ], "documents_to_update": ["docs/status.md", "CHANGELOG.md"], } self.write_manifests() git(self.root, "add", ".") git(self.root, "commit", "-m", "baseline") self.base = git(self.root, "rev-parse", "HEAD") def write_manifests(self) -> None: write_json(self.root / ".process/config.json", self.config) write_json(self.root / "docs/requirements/REQ-APP-VALUE.json", self.requirement) write_json(self.root / "docs/tasks/PROJ-1.json", self.task) def make_valid_change(self) -> None: (self.root / "src/app.py").write_text("VALUE = 2\n", encoding="utf-8") (self.root / "docs/status.md").write_text("# Status\nValue updated.\n", encoding="utf-8") (self.root / "CHANGELOG.md").write_text("# Changelog\nValue updated.\n", encoding="utf-8") self.task["status"] = "ready_for_verification" self.write_manifests() def commit(self, message: str = "change") -> str: git(self.root, "add", ".") git(self.root, "commit", "-m", message) return git(self.root, "rev-parse", "HEAD") def close(self) -> None: self.temp.cleanup() def invoke(*args: str) -> tuple[int, str]: output = io.StringIO() with contextlib.redirect_stdout(output), contextlib.redirect_stderr(output): result = pp.main(list(args)) return result, output.getvalue() class ProjectProcessTests(unittest.TestCase): def setUp(self) -> None: self.fixture = RepositoryFixture() def tearDown(self) -> None: self.fixture.close() def test_valid_manifests_pass_lint(self) -> None: result, output = invoke("lint", "--root", str(self.fixture.root)) self.assertEqual(result, 0, output) def test_lint_before_first_git_commit_passes_when_no_archive_exists(self) -> None: shutil.rmtree(self.fixture.root / ".git") result, output = invoke("lint", "--root", str(self.fixture.root)) self.assertEqual(result, 0, output) def test_bundled_json_schemas_and_examples_are_valid_json(self) -> None: strict_root = Path(__file__).parents[1] / "assets" / "strict" for path in strict_root.rglob("*.json"): with self.subTest(path=path): value = pp.load_json(path) self.assertIsInstance(value, dict) schema = value.get("$schema") if schema and not schema.startswith("https://"): self.assertTrue((path.parent / schema).resolve().is_file()) @unittest.skipIf(jsonschema is None, "jsonschema is not installed") def test_bundled_examples_conform_to_json_schemas(self) -> None: strict_root = Path(__file__).parents[1] / "assets" / "strict" schema_root = strict_root / ".process" / "schemas" schemas = { "config": pp.load_json(schema_root / "config.schema.json"), "requirement": pp.load_json(schema_root / "requirement.schema.json"), "task": pp.load_json(schema_root / "task.schema.json"), } for schema in schemas.values(): jsonschema.Draft202012Validator.check_schema(schema) jsonschema.validate(pp.load_json(strict_root / ".process" / "config.json"), schemas["config"]) jsonschema.validate( pp.load_json(strict_root / "docs" / "requirements" / "REQ-PROJECT-PROCESS.json"), schemas["requirement"], ) jsonschema.validate( pp.load_json(strict_root / "docs" / "tasks" / "PROJ-1.json"), schemas["task"], ) def test_valid_diff_and_nonempty_junit_pass_gate(self) -> None: self.fixture.make_valid_change() head = self.fixture.commit() result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", self.fixture.base, "--head", head, "--policy-ref", self.fixture.base, ) self.assertEqual(result, 0, output) def test_planning_only_change_passes_without_execution_checks(self) -> None: self.fixture.requirement["statement"] = "The application exposes a planned value." self.fixture.task["goal"] = "Plan the application value change." self.fixture.write_manifests() head = self.fixture.commit("planning update") result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", self.fixture.base, "--head", head, "--policy-ref", self.fixture.base, ) self.assertEqual(result, 0, output) def test_ready_task_can_be_archived_with_existing_merged_sha(self) -> None: self.fixture.make_valid_change() implementation = self.fixture.commit("implementation") self.fixture.task["status"] = "archived" self.fixture.task["merged_commit_sha"] = implementation self.fixture.write_manifests() archived = self.fixture.commit("archive task") result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", implementation, "--head", archived, "--policy-ref", implementation, ) self.assertEqual(result, 0, output) def test_dangling_requirement_fails(self) -> None: self.fixture.task["requirement_ids"] = ["REQ-MISSING-ONE"] self.fixture.write_manifests() result, output = invoke("lint", "--root", str(self.fixture.root)) self.assertNotEqual(result, 0) self.assertIn("dangling requirement", output) def test_placeholder_fails(self) -> None: self.fixture.task["goal"] = "TODO" self.fixture.write_manifests() result, output = invoke("lint", "--root", str(self.fixture.root)) self.assertNotEqual(result, 0) self.assertIn("placeholder", output) def test_short_sha_fails_without_fallback(self) -> None: self.fixture.make_valid_change() head = self.fixture.commit() result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", self.fixture.base[:8], "--head", head, "--policy-ref", self.fixture.base[:8], ) self.assertNotEqual(result, 0) self.assertIn("full lowercase 40-character", output) def test_policy_ref_must_equal_base(self) -> None: self.fixture.make_valid_change() head = self.fixture.commit() result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", self.fixture.base, "--head", head, "--policy-ref", head, ) self.assertNotEqual(result, 0) self.assertIn("must exactly equal --base", output) def test_checkout_must_equal_tested_head(self) -> None: self.fixture.make_valid_change() head = self.fixture.commit() git(self.fixture.root, "checkout", self.fixture.base) result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", self.fixture.base, "--head", head, "--policy-ref", self.fixture.base, ) self.assertNotEqual(result, 0) self.assertIn("checked-out HEAD does not exactly equal --head", output) def test_dirty_worktree_fails(self) -> None: self.fixture.make_valid_change() head = self.fixture.commit() (self.fixture.root / "src/app.py").write_text("VALUE = 999\n", encoding="utf-8") result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", self.fixture.base, "--head", head, "--policy-ref", self.fixture.base, ) self.assertNotEqual(result, 0) self.assertIn("working tree must be clean", output) def test_unknown_changed_path_fails_default_deny(self) -> None: self.fixture.make_valid_change() (self.fixture.root / "unknown.bin").write_bytes(b"data") head = self.fixture.commit() result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", self.fixture.base, "--head", head, "--policy-ref", self.fixture.base, ) self.assertNotEqual(result, 0) self.assertIn("unknown changed path", output) def test_rename_into_unknown_path_fails(self) -> None: self.fixture.make_valid_change() (self.fixture.root / "unmapped").mkdir() git(self.fixture.root, "mv", "src/app.py", "unmapped/app.py") head = self.fixture.commit() result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", self.fixture.base, "--head", head, "--policy-ref", self.fixture.base, ) self.assertNotEqual(result, 0) self.assertIn("unknown changed path has no impact rule: unmapped/app.py", output) @unittest.skipUnless(hasattr(Path, "symlink_to"), "symlinks unsupported") def test_symlink_escaping_repository_fails(self) -> None: self.fixture.make_valid_change() link = self.fixture.root / "docs" / "outside-link" link.symlink_to(Path(self.fixture.temp.name).parent) head = self.fixture.commit() result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", self.fixture.base, "--head", head, "--policy-ref", self.fixture.base, ) self.assertNotEqual(result, 0) self.assertIn("changed symlink escapes repository", output) def test_base_must_be_ancestor_of_head(self) -> None: self.fixture.make_valid_change() head = self.fixture.commit("valid branch") git(self.fixture.root, "switch", "-c", "other", self.fixture.base) (self.fixture.root / "docs/status.md").write_text("# Status\nOther branch.\n", encoding="utf-8") other = self.fixture.commit("other branch") git(self.fixture.root, "checkout", head) result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", other, "--head", head, "--policy-ref", other, ) self.assertNotEqual(result, 0) self.assertIn("base must be an ancestor", output) def test_changed_path_outside_task_scope_fails(self) -> None: self.fixture.make_valid_change() self.fixture.task["affected_paths"] = ["docs/**", "CHANGELOG.md"] self.fixture.write_manifests() head = self.fixture.commit() result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", self.fixture.base, "--head", head, "--policy-ref", self.fixture.base, ) self.assertNotEqual(result, 0) self.assertIn("outside every eligible task scope: src/app.py", output) def test_policy_change_fails_even_if_candidate_weakens_policy(self) -> None: self.fixture.make_valid_change() self.fixture.config["protected_paths"] = [] self.fixture.write_manifests() head = self.fixture.commit() result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", self.fixture.base, "--head", head, "--policy-ref", self.fixture.base, ) self.assertNotEqual(result, 0) self.assertIn("protected process path changed", output) def test_zero_tests_junit_fails(self) -> None: self.fixture.close() self.fixture = RepositoryFixture(executed_tests=0) self.fixture.make_valid_change() head = self.fixture.commit() result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", self.fixture.base, "--head", head, "--policy-ref", self.fixture.base, ) self.assertNotEqual(result, 0) self.assertIn("must contain testsuite and testcase", output) def test_forged_junit_counter_without_testcase_fails(self) -> None: self.fixture.close() self.fixture = RepositoryFixture(fake_junit=True) self.fixture.make_valid_change() head = self.fixture.commit() result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", self.fixture.base, "--head", head, "--policy-ref", self.fixture.base, ) self.assertNotEqual(result, 0) self.assertIn("must contain testsuite and testcase", output) def test_stale_ready_task_cannot_cover_new_change(self) -> None: self.fixture.make_valid_change() first_head = self.fixture.commit("first implementation") (self.fixture.root / "src/app.py").write_text("VALUE = 3\n", encoding="utf-8") (self.fixture.root / "docs/status.md").write_text("# Status\nValue updated again.\n", encoding="utf-8") (self.fixture.root / "CHANGELOG.md").write_text("# Changelog\nValue updated again.\n", encoding="utf-8") second_head = self.fixture.commit("untracked second implementation") result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", first_head, "--head", second_head, "--policy-ref", first_head, ) self.assertNotEqual(result, 0) self.assertIn("no changed task transitioning", output) def test_check_may_not_modify_tracked_files(self) -> None: self.fixture.close() self.fixture = RepositoryFixture(mutate_tracked=True) self.fixture.make_valid_change() head = self.fixture.commit() result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", self.fixture.base, "--head", head, "--policy-ref", self.fixture.base, ) self.assertNotEqual(result, 0) self.assertIn("check command changed the repository", output) def test_archived_task_requires_existing_ancestor_commit(self) -> None: self.fixture.task["status"] = "archived" self.fixture.task["merged_commit_sha"] = "0" * 40 self.fixture.write_manifests() result, output = invoke("lint", "--root", str(self.fixture.root)) self.assertNotEqual(result, 0) self.assertIn("merged_commit_sha must exist", output) def test_archived_task_rejects_preimplementation_ancestor(self) -> None: self.fixture.make_valid_change() implementation = self.fixture.commit("implementation") self.fixture.task["status"] = "archived" self.fixture.task["merged_commit_sha"] = self.fixture.base self.fixture.write_manifests() archived = self.fixture.commit("archive with stale sha") result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", implementation, "--head", archived, "--policy-ref", implementation, ) self.assertNotEqual(result, 0) self.assertIn("does not contain this task in ready_for_verification", output) def test_source_of_truth_must_exist(self) -> None: self.fixture.task["source_of_truth"] = ["docs/requirements/REQ-MISSING.json"] self.fixture.write_manifests() result, output = invoke("lint", "--root", str(self.fixture.root)) self.assertNotEqual(result, 0) self.assertIn("source_of_truth does not exist", output) def test_requirement_filename_must_equal_id(self) -> None: original = self.fixture.root / "docs/requirements/REQ-APP-VALUE.json" renamed = self.fixture.root / "docs/requirements/REQ-WRONG-NAME.json" original.rename(renamed) result, output = invoke("lint", "--root", str(self.fixture.root)) self.assertNotEqual(result, 0) self.assertIn("filename must equal requirement ID", output) def test_requirement_deletion_is_forbidden(self) -> None: self.fixture.make_valid_change() (self.fixture.root / "docs/requirements/REQ-APP-VALUE.json").unlink() head = self.fixture.commit() result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", self.fixture.base, "--head", head, "--policy-ref", self.fixture.base, ) self.assertNotEqual(result, 0) self.assertIn("was deleted; mark it deprecated", output) def test_workflow_contract_uses_exact_head_and_merge_group(self) -> None: workflow = ( Path(__file__).parents[1] / "assets/strict/.github/workflows/project-process.yml" ).read_text(encoding="utf-8") self.assertIn("merge_group:", workflow) self.assertIn("ref: ${{ steps.revisions.outputs.head }}", workflow) self.assertIn('git show "$BASE:scripts/project_process.py"', workflow) self.assertIn('--policy-ref "$BASE"', workflow) def test_workflow_pins_external_actions_to_full_sha(self) -> None: workflow = ( Path(__file__).parents[1] / "assets/strict/.github/workflows/project-process.yml" ).read_text(encoding="utf-8") external_actions = [ line.split("uses:", 1)[1].split("#", 1)[0].strip() for line in workflow.splitlines() if "uses:" in line and not line.split("uses:", 1)[1].strip().startswith("./") ] self.assertEqual( external_actions, ["actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5"], ) for action in external_actions: self.assertRegex(action, r"^[^@\s]+@[0-9a-f]{40}$") def test_every_declared_document_must_change(self) -> None: self.fixture.make_valid_change() self.fixture.task["documents_to_update"].append("docs/questions.md") self.fixture.write_manifests() head = self.fixture.commit() result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", self.fixture.base, "--head", head, "--policy-ref", self.fixture.base, ) self.assertNotEqual(result, 0) self.assertIn("task PROJ-1 requires changed document: docs/questions.md", output) def test_missing_required_document_update_fails(self) -> None: self.fixture.make_valid_change() git(self.fixture.root, "restore", "docs/status.md") head = self.fixture.commit() result, output = invoke( "gate", "--root", str(self.fixture.root), "--base", self.fixture.base, "--head", head, "--policy-ref", self.fixture.base, ) self.assertNotEqual(result, 0) self.assertIn("impact rule requires changed document: docs/status.md", output) def test_duplicate_json_key_fails(self) -> None: config_path = self.fixture.root / ".process/config.json" raw = config_path.read_text(encoding="utf-8") config_path.write_text(raw.replace('"schema_version": 1,', '"schema_version": 1,\n "schema_version": 1,'), encoding="utf-8") result, output = invoke("lint", "--root", str(self.fixture.root)) self.assertNotEqual(result, 0) self.assertIn("duplicate JSON key", output) if __name__ == "__main__": unittest.main()