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
+393
@@ -0,0 +1,393 @@
|
||||
"""Repository input identities use disposable Git fixtures, never remote effects."""
|
||||
|
||||
from copy import deepcopy
|
||||
import os
|
||||
from pathlib import Path
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
import pytest
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "tools/devkit"))
|
||||
from govoplan_devkit import inputs
|
||||
from govoplan_devkit.inputs import InputSnapshotter, validate_input_declaration
|
||||
from govoplan_devkit.workspace import Project, Repository, source_fingerprint
|
||||
|
||||
|
||||
def git(repo, *arguments):
|
||||
return subprocess.run(
|
||||
["git", "-C", str(repo), *arguments], capture_output=True, check=True
|
||||
).stdout
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fixture(tmp_path, monkeypatch):
|
||||
repositories = []
|
||||
for name in ("alpha", "beta"):
|
||||
path = tmp_path / name
|
||||
path.mkdir()
|
||||
git(path, "init", "-q")
|
||||
(path / "source.txt").write_text("initial\n")
|
||||
git(path, "add", "source.txt")
|
||||
git(
|
||||
path,
|
||||
"-c",
|
||||
"user.name=Fixture",
|
||||
"-c",
|
||||
"user.email=fixture@example.invalid",
|
||||
"commit",
|
||||
"-qm",
|
||||
"fixture",
|
||||
)
|
||||
repositories.append(Repository(name, path, ("alias-" + name,)))
|
||||
project = Project(
|
||||
"Fixture",
|
||||
tuple(repositories),
|
||||
{"tools": {}, "profiles": {"quick": ["one"]}, "checks": []},
|
||||
)
|
||||
# Avoid incidental edits by other agents affecting these source-input tests.
|
||||
# The actual tool-source hashing implementation is checked separately.
|
||||
monkeypatch.setattr(
|
||||
InputSnapshotter, "_tooling_identity", lambda *_: "fixture-devkit-source"
|
||||
)
|
||||
return tmp_path, project, InputSnapshotter(project, workspace_root=tmp_path)
|
||||
|
||||
|
||||
def stage(identity="one", repos=None, **extra):
|
||||
return {
|
||||
"id": identity,
|
||||
"argv": ["true"],
|
||||
**({"inputs": {"repos": repos}} if repos is not None else {}),
|
||||
**extra,
|
||||
}
|
||||
|
||||
|
||||
def fingerprint(snapshot, name="one"):
|
||||
return snapshot["stages"][name]["fingerprint"]
|
||||
|
||||
|
||||
def test_undeclared_inputs_remain_whole_workspace(fixture):
|
||||
root, _, engine = fixture
|
||||
result = engine.snapshot([stage()])
|
||||
assert result["complete_workspace"] is True
|
||||
assert result["observed_scope"]["repos"] == ["alpha", "beta"]
|
||||
assert result["stages"]["one"]["scope"] == {
|
||||
"version": 1,
|
||||
"kind": "workspace",
|
||||
"declared": False,
|
||||
"repos": ["alpha", "beta"],
|
||||
}
|
||||
(root / "beta/source.txt").write_text("changed\n")
|
||||
assert fingerprint(engine.snapshot([stage()])) != fingerprint(result)
|
||||
|
||||
|
||||
def test_scoped_input_does_not_read_unrelated_repository_bytes(fixture, monkeypatch):
|
||||
root, _, engine = fixture
|
||||
original = inputs.os.open
|
||||
|
||||
def guarded(path, *args, **kwargs):
|
||||
if Path(path).is_relative_to(root / "beta"):
|
||||
raise AssertionError("Unrelated repository input was opened")
|
||||
return original(path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(inputs.os, "open", guarded)
|
||||
first = engine.snapshot([stage(repos=["alpha"])])
|
||||
(root / "beta/source.txt").write_text("unrelated change")
|
||||
second = engine.snapshot([stage(repos=["alpha"])])
|
||||
assert fingerprint(first) == fingerprint(second)
|
||||
assert second["scan_stats"]["repositories"] == 1
|
||||
assert second["scan_stats"]["git_calls"] == 2
|
||||
assert second["complete_workspace"] is False
|
||||
|
||||
|
||||
def test_repository_union_scans_once_per_snapshot(fixture):
|
||||
_, _, engine = fixture
|
||||
result = engine.snapshot(
|
||||
[
|
||||
stage("one", ["alpha"]),
|
||||
stage("two", ["alpha"]),
|
||||
stage("three", ["beta", "alpha"]),
|
||||
]
|
||||
)
|
||||
assert result["scan_stats"]["repositories"] == 2
|
||||
assert result["scan_stats"]["git_calls"] == 4
|
||||
assert result["stages"]["three"]["scope"]["repos"] == ["alpha", "beta"]
|
||||
|
||||
|
||||
def test_session_rechecks_metadata_but_reuses_stable_file_content(fixture):
|
||||
_, _, engine = fixture
|
||||
plan = [stage(repos=["alpha"])]
|
||||
before = engine.snapshot(plan)
|
||||
after = engine.snapshot(plan)
|
||||
assert fingerprint(before) == fingerprint(after)
|
||||
assert before["scan_stats"]["bytes"] == len("initial\n")
|
||||
assert after["scan_stats"]["bytes"] == 0
|
||||
assert after["scan_stats"]["cache_hits"] == 1
|
||||
assert after["scan_stats"]["git_calls"] == 2
|
||||
|
||||
|
||||
def test_changed_bytes_with_restored_mtime_are_not_reused(fixture):
|
||||
root, _, engine = fixture
|
||||
plan = [stage(repos=["alpha"])]
|
||||
before = engine.snapshot(plan)
|
||||
path = root / "alpha/source.txt"
|
||||
metadata = path.stat()
|
||||
path.write_text("changed\n")
|
||||
os.utime(path, ns=(metadata.st_atime_ns, metadata.st_mtime_ns))
|
||||
after = engine.snapshot(plan)
|
||||
assert fingerprint(after) != fingerprint(before)
|
||||
assert after["scan_stats"]["bytes"] == len("changed\n")
|
||||
|
||||
|
||||
def test_inode_replacement_with_same_size_and_mtime_is_not_reused(fixture):
|
||||
root, _, engine = fixture
|
||||
plan = [stage(repos=["alpha"])]
|
||||
before = engine.snapshot(plan)
|
||||
path = root / "alpha/source.txt"
|
||||
metadata = path.stat()
|
||||
replacement = root / "replacement.txt"
|
||||
replacement.write_text("changed\n")
|
||||
os.utime(replacement, ns=(metadata.st_atime_ns, metadata.st_mtime_ns))
|
||||
replacement.replace(path)
|
||||
after = engine.snapshot(plan)
|
||||
assert fingerprint(before) != fingerprint(after)
|
||||
assert after["scan_stats"]["cache_hits"] == 0
|
||||
|
||||
|
||||
@pytest.mark.parametrize("flag", ["assume-unchanged", "skip-worktree"])
|
||||
def test_hidden_index_flags_do_not_hide_worktree_changes(fixture, flag):
|
||||
root, _, engine = fixture
|
||||
git(root / "alpha", "update-index", "--" + flag, "source.txt")
|
||||
before = engine.source_snapshot(["alpha"])
|
||||
(root / "alpha/source.txt").write_text("hidden change\n")
|
||||
after = engine.source_snapshot(["alpha"])
|
||||
assert before["observed_source_fingerprint"] != after["observed_source_fingerprint"]
|
||||
|
||||
|
||||
def test_index_head_and_new_deleted_files_are_bound(fixture):
|
||||
root, _, engine = fixture
|
||||
repo = root / "alpha"
|
||||
identities = []
|
||||
|
||||
def record():
|
||||
identities.append(
|
||||
engine.source_snapshot(["alpha"])["observed_source_fingerprint"]
|
||||
)
|
||||
|
||||
record()
|
||||
(repo / "source.txt").write_text("changed\n")
|
||||
record()
|
||||
git(repo, "add", "source.txt")
|
||||
record()
|
||||
git(
|
||||
repo,
|
||||
"-c",
|
||||
"user.name=Fixture",
|
||||
"-c",
|
||||
"user.email=fixture@example.invalid",
|
||||
"commit",
|
||||
"-qm",
|
||||
"change",
|
||||
)
|
||||
record()
|
||||
(repo / "new.txt").write_text("new")
|
||||
record()
|
||||
(repo / "source.txt").unlink()
|
||||
record()
|
||||
assert len(set(identities)) == len(identities)
|
||||
|
||||
|
||||
def test_missing_registered_repository_becoming_present_invalidates(fixture):
|
||||
root, project, _ = fixture
|
||||
extra = Repository("missing", root / "missing")
|
||||
extended = Project("Fixture", (*project.repositories, extra), project.config)
|
||||
engine = InputSnapshotter(extended, workspace_root=root)
|
||||
before = engine.source_snapshot(["missing"])
|
||||
extra.path.mkdir()
|
||||
git(extra.path, "init", "-q")
|
||||
after = engine.source_snapshot(["missing"])
|
||||
assert before["observed_source_fingerprint"] != after["observed_source_fingerprint"]
|
||||
|
||||
|
||||
def test_unrelated_plan_config_edits_do_not_invalidate_scoped_stage(fixture):
|
||||
root, project, engine = fixture
|
||||
plan = [stage(repos=["alpha"])]
|
||||
before = engine.snapshot(plan)
|
||||
updated = deepcopy(project.config)
|
||||
updated["profiles"]["full"] = ["unrelated"]
|
||||
updated["checks"].append({"id": "unrelated", "argv": ["false"]})
|
||||
other = InputSnapshotter(
|
||||
Project("Changed label", project.repositories, updated), workspace_root=root
|
||||
)
|
||||
assert fingerprint(other.snapshot(plan)) == fingerprint(before)
|
||||
|
||||
|
||||
def test_scope_command_tools_and_tooling_change_invalidate(fixture):
|
||||
root, project, engine = fixture
|
||||
first = engine.snapshot([stage(repos=["alpha"])])
|
||||
assert fingerprint(engine.snapshot([stage(repos=["beta"])])) != fingerprint(first)
|
||||
assert fingerprint(
|
||||
engine.snapshot([stage(repos=["alpha"], argv=["false"])])
|
||||
) != fingerprint(first)
|
||||
assert fingerprint(
|
||||
engine.snapshot([stage(repos=["alpha"])], tooling_fingerprint="new-env")
|
||||
) != fingerprint(first)
|
||||
changed = deepcopy(project.config)
|
||||
changed["tools"] = {"node": "/another/node"}
|
||||
other = InputSnapshotter(
|
||||
Project("Fixture", project.repositories, changed), workspace_root=root
|
||||
)
|
||||
assert fingerprint(other.snapshot([stage(repos=["alpha"])])) != fingerprint(first)
|
||||
|
||||
|
||||
def test_source_only_attestation_is_independent_of_plan_and_tooling(fixture):
|
||||
_, _, engine = fixture
|
||||
one = engine.snapshot([stage(repos=["alpha"])], tooling_fingerprint="env-one")
|
||||
two = engine.snapshot(
|
||||
[stage("different", ["alpha"], argv=["false"])], tooling_fingerprint="env-two"
|
||||
)
|
||||
source = engine.source_snapshot(["alpha"])
|
||||
assert (
|
||||
one["observed_source_fingerprint"]
|
||||
== two["observed_source_fingerprint"]
|
||||
== source["observed_source_fingerprint"]
|
||||
)
|
||||
assert (
|
||||
one["stages"]["one"]["source_fingerprint"]
|
||||
== source["observed_source_fingerprint"]
|
||||
)
|
||||
assert source["fingerprint_version"] == inputs.FINGERPRINT_VERSION
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"value",
|
||||
[
|
||||
None,
|
||||
{},
|
||||
{"paths": ["src/**"]},
|
||||
{"repos": []},
|
||||
{"repos": "alpha"},
|
||||
{"repos": ["unknown"]},
|
||||
{"repos": ["alias-alpha"]},
|
||||
{"repos": ["alpha", "alpha"]},
|
||||
{"repos": [""]},
|
||||
{"repos": ["alpha"], "extra": True},
|
||||
],
|
||||
)
|
||||
def test_synthetic_stage_scopes_fail_closed(fixture, value):
|
||||
_, _, engine = fixture
|
||||
with pytest.raises(ValueError):
|
||||
engine.snapshot([stage(inputs=value)])
|
||||
|
||||
|
||||
def test_duplicate_stage_ids_and_escaped_repository_paths_fail(fixture):
|
||||
root, project, engine = fixture
|
||||
with pytest.raises(ValueError, match="Duplicate"):
|
||||
engine.snapshot([stage(), stage()])
|
||||
bad = Project("Bad", (Repository("outside", root.parent),), project.config)
|
||||
with pytest.raises(ValueError, match="escapes"):
|
||||
InputSnapshotter(bad, workspace_root=root)
|
||||
|
||||
|
||||
def test_declaration_validation_is_pure_before_dry_run(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
inputs,
|
||||
"git_bytes",
|
||||
lambda *_a, **_k: pytest.fail("Planning validation ran Git"),
|
||||
)
|
||||
assert validate_input_declaration(
|
||||
{"repos": ["beta", "alpha"]}, {"alpha", "beta"}
|
||||
) == {"repos": ["alpha", "beta"]}
|
||||
with pytest.raises(ValueError):
|
||||
validate_input_declaration({"repos": ["unknown"]}, {"alpha", "beta"})
|
||||
|
||||
|
||||
def test_runtime_records_are_not_hashed_as_execution_plans(fixture):
|
||||
_, _, engine = fixture
|
||||
with pytest.raises(ValueError, match="freshly planned"):
|
||||
engine.snapshot([stage(repos=["alpha"], status="passed")])
|
||||
|
||||
|
||||
def test_membership_count_is_bounded_before_entry_hashing(fixture, monkeypatch):
|
||||
root, _, engine = fixture
|
||||
(root / "alpha/new.txt").write_text("extra")
|
||||
monkeypatch.setattr(inputs, "MAX_REPOSITORY_ENTRIES", 1)
|
||||
with pytest.raises(ValueError, match="entry count"):
|
||||
engine.source_snapshot(["alpha"])
|
||||
|
||||
|
||||
def test_symlink_to_ignored_file_inside_repository_binds_target(fixture):
|
||||
root, _, engine = fixture
|
||||
repo = root / "alpha"
|
||||
(repo / ".gitignore").write_text("ignored.txt\n")
|
||||
(repo / "ignored.txt").write_text("first")
|
||||
(repo / "linked.txt").symlink_to("ignored.txt")
|
||||
before = engine.source_snapshot(["alpha"])
|
||||
(repo / "ignored.txt").write_text("other")
|
||||
assert (
|
||||
engine.source_snapshot(["alpha"])["observed_source_fingerprint"]
|
||||
!= before["observed_source_fingerprint"]
|
||||
)
|
||||
|
||||
|
||||
def test_dangling_symlink_target_creation_is_observed(fixture):
|
||||
root, _, engine = fixture
|
||||
repo = root / "alpha"
|
||||
(repo / "linked.txt").symlink_to("future.txt")
|
||||
before = engine.source_snapshot(["alpha"])
|
||||
(repo / "future.txt").write_text("created")
|
||||
assert (
|
||||
engine.source_snapshot(["alpha"])["observed_source_fingerprint"]
|
||||
!= before["observed_source_fingerprint"]
|
||||
)
|
||||
|
||||
|
||||
def test_cross_repository_and_directory_symlinks_are_not_silently_reused(fixture):
|
||||
root, _, engine = fixture
|
||||
link = root / "alpha/linked.txt"
|
||||
link.symlink_to(root / "beta/source.txt")
|
||||
with pytest.raises(ValueError, match="escapes"):
|
||||
engine.source_snapshot(["alpha"])
|
||||
link.unlink()
|
||||
link.symlink_to(".")
|
||||
with pytest.raises(ValueError, match="regular file"):
|
||||
engine.source_snapshot(["alpha"])
|
||||
|
||||
|
||||
def test_file_change_during_hash_is_rejected(fixture, monkeypatch):
|
||||
root, _, engine = fixture
|
||||
path = root / "alpha/source.txt"
|
||||
original = inputs.os.fstat
|
||||
changed = False
|
||||
|
||||
def mutate(descriptor):
|
||||
nonlocal changed
|
||||
metadata = original(descriptor)
|
||||
if not changed:
|
||||
changed = True
|
||||
path.write_text("changed during read")
|
||||
return metadata
|
||||
|
||||
monkeypatch.setattr(inputs.os, "fstat", mutate)
|
||||
with pytest.raises(ValueError, match="changed"):
|
||||
engine._file_hash(path, inputs._stats())
|
||||
|
||||
|
||||
def test_legacy_whole_project_api_remains_independent(fixture):
|
||||
_, project, engine = fixture
|
||||
legacy = source_fingerprint(project)
|
||||
engine.snapshot([stage(repos=["alpha"])])
|
||||
assert source_fingerprint(project) == legacy
|
||||
|
||||
|
||||
def test_real_tooling_inventory_is_bound_and_memoized(tmp_path):
|
||||
repo = Repository("example", tmp_path)
|
||||
project = Project("Fixture", (repo,), {"tools": {}})
|
||||
engine = InputSnapshotter(project, workspace_root=tmp_path)
|
||||
before_stats, after_stats = inputs._stats(), inputs._stats()
|
||||
before = engine._tooling_identity(before_stats)
|
||||
after = engine._tooling_identity(after_stats)
|
||||
assert before == after
|
||||
assert before_stats["tooling_files"] > 15 and before_stats["tooling_bytes"] > 0
|
||||
assert after_stats["tooling_files"] == after_stats["tooling_cache_hits"]
|
||||
assert after_stats["tooling_bytes"] == 0
|
||||
Reference in New Issue
Block a user