feat(devkit): add resumable workspace automation and UI review tooling
Verified with the coordinated workspace changes by devkit full run 2026-09-08T225814-186389-0000-3e3ed7cd (all seven phases passed). This shared UI pass does not mark the individual module reviews complete.
This commit is contained in:
Executable
+378
@@ -0,0 +1,378 @@
|
||||
"""Environment probes read bounded stable files; all fixtures are local."""
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
from pathlib import Path
|
||||
import sys
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit"))
|
||||
from govoplan_devkit import environment
|
||||
from govoplan_devkit.common import digest
|
||||
from govoplan_devkit.workspace import Project, Repository
|
||||
|
||||
|
||||
def mutate_during_read(monkeypatch, callback):
|
||||
original = hashlib.sha256
|
||||
mutated = False
|
||||
|
||||
class MutatingHasher:
|
||||
def __init__(self):
|
||||
self.hasher = original()
|
||||
|
||||
def update(self, chunk):
|
||||
nonlocal mutated
|
||||
self.hasher.update(chunk)
|
||||
if not mutated:
|
||||
mutated = True
|
||||
callback()
|
||||
|
||||
def hexdigest(self):
|
||||
return self.hasher.hexdigest()
|
||||
|
||||
monkeypatch.setattr(environment.hashlib, "sha256", MutatingHasher)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("content", [b"", b"stable", b"x" * (1024 * 1024 + 3)])
|
||||
def test_file_hash_keeps_existing_digest_and_exact_size_boundary(tmp_path, content):
|
||||
path = tmp_path / "input"
|
||||
path.write_bytes(content)
|
||||
assert (
|
||||
environment._environment_file_hash(path, len(content))
|
||||
== hashlib.sha256(content).hexdigest()
|
||||
)
|
||||
|
||||
|
||||
def test_missing_optional_file_remains_optional_without_a_persistent_cache(tmp_path):
|
||||
path = tmp_path / "input"
|
||||
assert environment._environment_file_hash(path, 10) is None
|
||||
path.write_bytes(b"first")
|
||||
first = environment._environment_file_hash(path, 10)
|
||||
path.write_bytes(b"other")
|
||||
assert environment._environment_file_hash(path, 10) != first
|
||||
|
||||
|
||||
@pytest.mark.parametrize("kind", ["fifo", "directory", "oversized", "dangling"])
|
||||
def test_present_unsafe_input_is_rejected_without_blocking(tmp_path, kind):
|
||||
path = tmp_path / "input"
|
||||
if kind == "fifo":
|
||||
os.mkfifo(path)
|
||||
elif kind == "directory":
|
||||
path.mkdir()
|
||||
elif kind == "oversized":
|
||||
path.write_bytes(b"too large")
|
||||
else:
|
||||
path.symlink_to(tmp_path / "missing")
|
||||
with pytest.raises(ValueError, match="Environment input"):
|
||||
environment._environment_file_hash(path, 2)
|
||||
|
||||
|
||||
def test_stable_venv_executable_symlink_keeps_original_path_and_identity(tmp_path):
|
||||
target = tmp_path / "real-python"
|
||||
target.write_bytes(b"fixture binary")
|
||||
executable = tmp_path / "venv" / "bin" / "python"
|
||||
executable.parent.mkdir(parents=True)
|
||||
executable.symlink_to(target)
|
||||
repo = tmp_path / "repo"
|
||||
metadata = repo / "node_modules" / ".package-lock.json"
|
||||
metadata.parent.mkdir(parents=True)
|
||||
metadata.write_bytes(b'{"fixture":true}')
|
||||
project = Project("Fixture", (Repository("repo", repo),), {})
|
||||
tools = {"python": str(executable)}
|
||||
env = {"PATH": "fixture", "PWD": "ignored"}
|
||||
distributions = b'[["fixture", "1"]]\n'
|
||||
with (
|
||||
patch.object(environment, "tool_version", return_value="fixture-version"),
|
||||
patch.object(
|
||||
environment,
|
||||
"require_capture",
|
||||
return_value=SimpleNamespace(returncode=0, stdout=distributions),
|
||||
) as capture,
|
||||
):
|
||||
actual = environment.environment_fingerprint(tmp_path, project, tools, env)
|
||||
assert actual == digest(
|
||||
{
|
||||
"environment": {"PATH": "fixture"},
|
||||
"tools": {
|
||||
"python": {
|
||||
"path": str(executable),
|
||||
"version": "fixture-version",
|
||||
"sha256": hashlib.sha256(target.read_bytes()).hexdigest(),
|
||||
}
|
||||
},
|
||||
"installed": {
|
||||
str(metadata): hashlib.sha256(metadata.read_bytes()).hexdigest(),
|
||||
"python_distributions": hashlib.sha256(distributions).hexdigest(),
|
||||
},
|
||||
}
|
||||
)
|
||||
assert capture.call_args.args[0][0] == str(executable)
|
||||
|
||||
|
||||
def test_symlinked_package_directory_is_allowed_when_stable(tmp_path):
|
||||
actual = tmp_path / "packages"
|
||||
actual.mkdir()
|
||||
(actual / ".package-lock.json").write_bytes(b"fixture")
|
||||
link = tmp_path / "node_modules"
|
||||
link.symlink_to(actual, target_is_directory=True)
|
||||
assert environment._environment_file_hash(link / ".package-lock.json", 10)
|
||||
|
||||
|
||||
def test_fifo_replacement_between_inspection_and_open_does_not_block(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
path = tmp_path / "input"
|
||||
path.write_bytes(b"fixture")
|
||||
real_open = os.open
|
||||
|
||||
def replace_before_open(target, flags):
|
||||
path.unlink()
|
||||
os.mkfifo(path)
|
||||
assert flags & os.O_NONBLOCK
|
||||
return real_open(target, flags)
|
||||
|
||||
monkeypatch.setattr(environment.os, "open", replace_before_open)
|
||||
with pytest.raises(ValueError, match="bounded regular"):
|
||||
environment._environment_file_hash(path, 10)
|
||||
|
||||
|
||||
def test_growth_between_inspection_and_open_cannot_bypass_size_bound(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
path = tmp_path / "input"
|
||||
path.write_bytes(b"a")
|
||||
real_open = os.open
|
||||
|
||||
def grow_before_open(target, flags):
|
||||
path.write_bytes(b"x" * 11)
|
||||
return real_open(target, flags)
|
||||
|
||||
monkeypatch.setattr(environment.os, "open", grow_before_open)
|
||||
with pytest.raises(ValueError, match="bounded regular"):
|
||||
environment._environment_file_hash(path, 10)
|
||||
|
||||
|
||||
def test_growth_during_read_cannot_bypass_size_bound(tmp_path, monkeypatch):
|
||||
path = tmp_path / "input"
|
||||
path.write_bytes(b"a")
|
||||
mutate_during_read(monkeypatch, lambda: path.write_bytes(b"x" * 11))
|
||||
with pytest.raises(ValueError, match="grew beyond"):
|
||||
environment._environment_file_hash(path, 10)
|
||||
|
||||
|
||||
def test_same_size_edit_with_restored_mtime_is_not_a_stable_identity(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
path = tmp_path / "input"
|
||||
path.write_bytes(b"first")
|
||||
metadata = path.stat()
|
||||
|
||||
def mutate():
|
||||
path.write_bytes(b"other")
|
||||
os.utime(path, ns=(metadata.st_atime_ns, metadata.st_mtime_ns))
|
||||
|
||||
mutate_during_read(monkeypatch, mutate)
|
||||
with pytest.raises(ValueError, match="changed during"):
|
||||
environment._environment_file_hash(path, 10)
|
||||
|
||||
|
||||
def test_replacement_with_identical_bytes_is_not_a_stable_identity(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
path = tmp_path / "input"
|
||||
path.write_bytes(b"fixture")
|
||||
replacement = tmp_path / "replacement"
|
||||
replacement.write_bytes(b"fixture")
|
||||
mutate_during_read(monkeypatch, lambda: replacement.replace(path))
|
||||
with pytest.raises(ValueError, match="changed during"):
|
||||
environment._environment_file_hash(path, 10)
|
||||
|
||||
|
||||
def test_symlink_retarget_with_identical_bytes_is_not_a_stable_identity(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
first, second, link = (tmp_path / name for name in ("first", "second", "python"))
|
||||
first.write_bytes(b"fixture")
|
||||
second.write_bytes(b"fixture")
|
||||
link.symlink_to(first)
|
||||
|
||||
def retarget():
|
||||
link.unlink()
|
||||
link.symlink_to(second)
|
||||
|
||||
mutate_during_read(monkeypatch, retarget)
|
||||
with pytest.raises(ValueError, match="changed during"):
|
||||
environment._environment_file_hash(link, 10)
|
||||
|
||||
|
||||
def test_source_disappearing_during_read_fails_closed(tmp_path, monkeypatch):
|
||||
path = tmp_path / "input"
|
||||
path.write_bytes(b"fixture")
|
||||
mutate_during_read(monkeypatch, path.unlink)
|
||||
with pytest.raises(ValueError, match="Environment input"):
|
||||
environment._environment_file_hash(path, 10)
|
||||
|
||||
|
||||
def test_parent_symlink_retarget_is_rejected_even_for_the_same_target_inode(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
first, second, link = (tmp_path / name for name in ("first", "second", "bin"))
|
||||
first.mkdir()
|
||||
second.mkdir()
|
||||
(first / "python").write_bytes(b"fixture")
|
||||
os.link(first / "python", second / "python")
|
||||
link.symlink_to(first, target_is_directory=True)
|
||||
|
||||
def retarget():
|
||||
link.unlink()
|
||||
link.symlink_to(second, target_is_directory=True)
|
||||
|
||||
mutate_during_read(monkeypatch, retarget)
|
||||
with pytest.raises(ValueError, match="changed during"):
|
||||
environment._environment_file_hash(link / "python", 10)
|
||||
|
||||
|
||||
def test_invalid_executable_is_rejected_before_any_version_process(tmp_path):
|
||||
executable = tmp_path / "python"
|
||||
os.mkfifo(executable)
|
||||
project = Project("Fixture", (), {})
|
||||
with patch.object(environment, "tool_version") as version:
|
||||
with pytest.raises(ValueError, match="bounded regular"):
|
||||
environment.environment_fingerprint(
|
||||
tmp_path, project, {"python": str(executable)}, {}
|
||||
)
|
||||
version.assert_not_called()
|
||||
|
||||
|
||||
def discovery_project(root, *, path="govoplan-backend"):
|
||||
repo = root / path
|
||||
(repo / "src").mkdir(parents=True)
|
||||
return Project(
|
||||
"GovOPlaN",
|
||||
(Repository("govoplan-backend", repo),),
|
||||
{"organization": "GovOPlaN"},
|
||||
)
|
||||
|
||||
|
||||
def test_native_shape_ignores_build_files_and_directory_timestamps(tmp_path):
|
||||
project = discovery_project(tmp_path)
|
||||
repo = project.repositories[0].path
|
||||
(repo / "webui").mkdir()
|
||||
before = environment._native_discovery_fingerprint(tmp_path, project)
|
||||
for directory in (repo, repo / "src", repo / "webui"):
|
||||
(directory / "temporary-build-output").write_text("changed")
|
||||
(directory / "temporary-build-directory").mkdir()
|
||||
assert environment._native_discovery_fingerprint(tmp_path, project) == before
|
||||
(repo / "webui" / "temporary-build-output").unlink()
|
||||
assert environment._native_discovery_fingerprint(tmp_path, project) == before
|
||||
|
||||
|
||||
def test_native_shape_detects_unknown_sibling_and_source_addition_removal(tmp_path):
|
||||
project = discovery_project(tmp_path)
|
||||
baseline = environment._native_discovery_fingerprint(tmp_path, project)
|
||||
sibling = tmp_path / "govoplan-unregistered"
|
||||
sibling.mkdir()
|
||||
empty = environment._native_discovery_fingerprint(tmp_path, project)
|
||||
assert empty != baseline
|
||||
for name in ("src", "webui"):
|
||||
(sibling / name).mkdir()
|
||||
assert environment._native_discovery_fingerprint(tmp_path, project) != empty
|
||||
(sibling / name).rmdir()
|
||||
assert environment._native_discovery_fingerprint(tmp_path, project) == empty
|
||||
sibling.rmdir()
|
||||
assert environment._native_discovery_fingerprint(tmp_path, project) == baseline
|
||||
|
||||
|
||||
@pytest.mark.parametrize("repo_path", ["govoplan-backend", "nonstandard-layout"])
|
||||
def test_native_shape_detects_registered_backend_gaining_webui(tmp_path, repo_path):
|
||||
project = discovery_project(tmp_path, path=repo_path)
|
||||
baseline = environment._native_discovery_fingerprint(tmp_path, project)
|
||||
webui = project.repositories[0].path / "webui"
|
||||
webui.mkdir()
|
||||
assert environment._native_discovery_fingerprint(tmp_path, project) != baseline
|
||||
webui.rmdir()
|
||||
assert environment._native_discovery_fingerprint(tmp_path, project) == baseline
|
||||
|
||||
|
||||
def test_native_shape_binds_registered_ownership_names(tmp_path):
|
||||
project = discovery_project(tmp_path, path="nonstandard-layout")
|
||||
renamed = Project(
|
||||
project.name,
|
||||
(Repository("govoplan-renamed", project.repositories[0].path),),
|
||||
project.config,
|
||||
)
|
||||
assert environment._native_discovery_fingerprint(
|
||||
tmp_path, project
|
||||
) != environment._native_discovery_fingerprint(tmp_path, renamed)
|
||||
|
||||
|
||||
def test_native_shape_detects_source_link_retarget_and_dangling_target(tmp_path):
|
||||
project = discovery_project(tmp_path)
|
||||
first, second = tmp_path / "first", tmp_path / "second"
|
||||
first.mkdir()
|
||||
second.mkdir()
|
||||
link = project.repositories[0].path / "webui"
|
||||
link.symlink_to(first, target_is_directory=True)
|
||||
baseline = environment._native_discovery_fingerprint(tmp_path, project)
|
||||
link.unlink()
|
||||
link.symlink_to(second, target_is_directory=True)
|
||||
changed = environment._native_discovery_fingerprint(tmp_path, project)
|
||||
assert changed != baseline
|
||||
second.rmdir()
|
||||
assert environment._native_discovery_fingerprint(tmp_path, project) != changed
|
||||
|
||||
|
||||
def test_native_shape_encodes_non_directory_source_entries(tmp_path):
|
||||
project = discovery_project(tmp_path)
|
||||
webui = project.repositories[0].path / "webui"
|
||||
baseline = environment._native_discovery_fingerprint(tmp_path, project)
|
||||
webui.write_text("not a directory")
|
||||
file_shape = environment._native_discovery_fingerprint(tmp_path, project)
|
||||
assert file_shape != baseline
|
||||
webui.unlink()
|
||||
webui.mkdir()
|
||||
assert environment._native_discovery_fingerprint(tmp_path, project) != file_shape
|
||||
|
||||
|
||||
def test_native_shape_audit_is_bounded_including_nonmatching_children(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
project = discovery_project(tmp_path)
|
||||
(tmp_path / "unrelated-one").mkdir()
|
||||
(tmp_path / "unrelated-two").mkdir()
|
||||
monkeypatch.setattr(environment, "MAX_DISCOVERY_CHILDREN", 2)
|
||||
with pytest.raises(ValueError, match="bounded ownership"):
|
||||
environment._native_discovery_fingerprint(tmp_path, project)
|
||||
|
||||
|
||||
def test_native_shape_resolution_loop_fails_closed(tmp_path):
|
||||
project = discovery_project(tmp_path)
|
||||
link = project.repositories[0].path / "webui"
|
||||
link.symlink_to(link)
|
||||
with pytest.raises(ValueError, match="discovery cannot be resolved"):
|
||||
environment._native_discovery_fingerprint(tmp_path, project)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("portable", [False, True])
|
||||
def test_only_native_environment_identity_binds_discovery_shape(tmp_path, portable):
|
||||
project = discovery_project(tmp_path)
|
||||
if portable:
|
||||
project.config["schema_version"] = 1
|
||||
executable = tmp_path / "fixture-python"
|
||||
executable.write_text("not executed")
|
||||
tools = {"python": str(executable)}
|
||||
with (
|
||||
patch.object(environment, "tool_version", return_value="fixture"),
|
||||
patch.object(
|
||||
environment,
|
||||
"require_capture",
|
||||
return_value=SimpleNamespace(returncode=0, stdout=b"[]"),
|
||||
),
|
||||
):
|
||||
baseline = environment.environment_fingerprint(tmp_path, project, tools, {})
|
||||
(tmp_path / "govoplan-new" / "src").mkdir(parents=True)
|
||||
changed = environment.environment_fingerprint(tmp_path, project, tools, {})
|
||||
assert (baseline == changed) is portable
|
||||
Reference in New Issue
Block a user