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.
394 lines
14 KiB
Python
Executable File
394 lines
14 KiB
Python
Executable File
"""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
|