compforge-repokit 0.16.0__tar.gz → 0.17.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/AGENTS.md +1 -0
  2. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/PKG-INFO +1 -1
  3. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/pyproject.toml +1 -1
  4. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/__init__.py +2 -0
  5. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/dependencies.py +19 -0
  6. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/__init__.py +2 -0
  7. compforge_repokit-0.17.0/repokit/forge/connection.py +112 -0
  8. compforge_repokit-0.17.0/repokit/validation_report.py +99 -0
  9. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_dependencies.py +23 -0
  10. compforge_repokit-0.17.0/tests/test_forge_connection.py +44 -0
  11. compforge_repokit-0.17.0/tests/test_validation_report.py +88 -0
  12. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/uv.lock +1 -1
  13. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/.gitignore +0 -0
  14. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/Makefile +0 -0
  15. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/README.md +0 -0
  16. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/_inspect.py +0 -0
  17. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/_language.py +0 -0
  18. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/_layout.py +0 -0
  19. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/_process.py +0 -0
  20. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/budget.py +0 -0
  21. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/_http_state.py +0 -0
  22. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/_page.py +0 -0
  23. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/_rest.py +0 -0
  24. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/creation.py +0 -0
  25. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/github.py +0 -0
  26. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/gitlab.py +0 -0
  27. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/model.py +0 -0
  28. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/observation/__init__.py +0 -0
  29. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/observation/context.py +0 -0
  30. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/observation/model.py +0 -0
  31. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/observation/service.py +0 -0
  32. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/observation/store.py +0 -0
  33. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/git/__init__.py +0 -0
  34. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/git/_catalog.py +0 -0
  35. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/git/_staging.py +0 -0
  36. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/git/index.py +0 -0
  37. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/git/operations.py +0 -0
  38. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/git/remote.py +0 -0
  39. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/git/stash.py +0 -0
  40. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/git/state.py +0 -0
  41. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/languages.json +0 -0
  42. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/model.py +0 -0
  43. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/py.typed +0 -0
  44. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/repository.py +0 -0
  45. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/snapshot.py +0 -0
  46. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/tree.py +0 -0
  47. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/validation.py +0 -0
  48. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/conftest.py +0 -0
  49. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_budget.py +0 -0
  50. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_checkout_contract.py +0 -0
  51. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_forge.py +0 -0
  52. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_forge_http.py +0 -0
  53. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_forge_repositories.py +0 -0
  54. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_fork.py +0 -0
  55. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_git_facts.py +0 -0
  56. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_git_helpers.py +0 -0
  57. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_git_index.py +0 -0
  58. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_inspect.py +0 -0
  59. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_metadata_contract.py +0 -0
  60. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_operations.py +0 -0
  61. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_pr_creation.py +0 -0
  62. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_pr_observation.py +0 -0
  63. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_process.py +0 -0
  64. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_rebase.py +0 -0
  65. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_repository_metadata.py +0 -0
  66. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_repository_queries.py +0 -0
  67. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_scalar_contract.py +0 -0
  68. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_snapshot_scopes.py +0 -0
  69. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_staging.py +0 -0
  70. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_stash.py +0 -0
  71. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_tree.py +0 -0
  72. {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_validation.py +0 -0
@@ -10,6 +10,7 @@
10
10
  ```text
11
11
  repokit/
12
12
  ├── __init__.py / model.py # 公共 API、common 派生类型和 owner 查询
13
+ ├── validation_report.py # Go 验证 wire report 的解码与契约校验
13
14
  ├── validation.py # 原生 Makefile 规划、执行与整轮验证报告
14
15
  ├── dependencies.py # 只读依赖观察与显式锁定安装;workspace 安装根与回执
15
16
  ├── _inspect.py # 输入版本、整次调用预算与一致性观察
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: compforge-repokit
3
- Version: 0.16.0
3
+ Version: 0.17.0
4
4
  Summary: Native repository inspection, content identity, Git and Forge operations
5
5
  Requires-Python: >=3.11
6
6
  Requires-Dist: harness-common<0.7,>=0.6
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "compforge-repokit"
3
- version = "0.16.0"
3
+ version = "0.17.0"
4
4
  description = "Native repository inspection, content identity, Git and Forge operations"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.11"
@@ -9,6 +9,7 @@ from ._inspect import inspect as inspect
9
9
  from .budget import operation as operation
10
10
  from .dependencies import DependencyEnvironment as DependencyEnvironment
11
11
  from .dependencies import DependencyPreparation as DependencyPreparation
12
+ from .dependencies import dependency_witnesses as dependency_witnesses
12
13
  from .dependencies import inspect_dependencies as inspect_dependencies
13
14
  from .dependencies import prepare_dependencies as prepare_dependencies
14
15
  from .model import ComponentBinding as ComponentBinding
@@ -38,3 +39,4 @@ from .validation import lint as lint
38
39
  from .validation import plan_validation as plan_validation
39
40
  from .validation import test as test
40
41
  from .validation import validate as validate
42
+ from .validation_report import decode_validation_report as decode_validation_report
@@ -376,3 +376,22 @@ def prepare_dependencies(
376
376
  )
377
377
  finally:
378
378
  lock.release()
379
+
380
+
381
+ def dependency_witnesses(path: str | Path) -> dict[str, str] | None:
382
+ """Return receipt-backed input digests, or None when reuse is not justified.
383
+
384
+ @spec Receipt names and input fingerprints belong to RepoKit. No environments
385
+ yields an empty mapping; unreadable inputs raise rather than prove readiness.
386
+ @link component://docs/validation.md
387
+ """
388
+ environments = inspect_dependencies(path)
389
+ if any(environment.status != "ready" for environment in environments):
390
+ return None
391
+ witnesses = {}
392
+ for environment in environments:
393
+ for source in (*environment.inputs, environment.directory / _RECEIPT):
394
+ witnesses[str(source.resolve())] = hashlib.sha256(source.read_bytes()).hexdigest()
395
+ if inspect_dependencies(path) != environments:
396
+ return None
397
+ return dict(sorted(witnesses.items()))
@@ -1,5 +1,7 @@
1
1
  """Forge clients accept explicit endpoint and credentials; no application configuration."""
2
2
 
3
+ from .connection import ForgeConnection as ForgeConnection
4
+ from .connection import detect_provider as detect_provider
3
5
  from .creation import PullRequestCreation as PullRequestCreation
4
6
  from .creation import ensure_pull_request as ensure_pull_request
5
7
  from .github import GitHubForge as GitHubForge
@@ -0,0 +1,112 @@
1
+ """Explicit Forge access and observation assembly, without application configuration."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import hashlib
6
+ import json
7
+ from dataclasses import dataclass, field
8
+ from pathlib import Path
9
+ from typing import Literal
10
+
11
+ from harness_common import Forge as ForgeIdentity
12
+ from harness_common import Repository
13
+
14
+ from .github import GitHubForge
15
+ from .gitlab import GitLabForge
16
+ from .model import Forge, ForgeAuthError
17
+ from .observation import PullRequestContext, PullRequestObserver
18
+
19
+
20
+ def detect_provider(host: str, explicit_type: str | None = None) -> Literal["github", "gitlab"]:
21
+ if explicit_type in ("github", "gitlab"):
22
+ return "github" if explicit_type == "github" else "gitlab"
23
+ host = host.lower()
24
+ return "github" if host == "github.com" or host.startswith("github.") else "gitlab"
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class ForgeConnection:
29
+ """Access configuration; never reads a caller's config or persists credentials.
30
+
31
+ @spec Provider, Forge identity, endpoint and credential changes isolate caches.
32
+ Query targets are explicit and never inferred from fork provenance.
33
+ @link component://docs/pr.md
34
+ """
35
+
36
+ host: str
37
+ token: str = field(repr=False)
38
+ provider: Literal["github", "gitlab"] | None = None
39
+ api_host: str | None = None
40
+ state_dir: Path = field(default_factory=lambda: Path.home() / ".repocli")
41
+ timeout: int = 10
42
+
43
+ def __post_init__(self) -> None:
44
+ if not self.host or self.timeout <= 0:
45
+ raise ValueError("Forge host and positive timeout are required")
46
+ if not self.token.strip():
47
+ raise ForgeAuthError(f"PR query unavailable: no credential for {self.host}")
48
+ object.__setattr__(self, "provider", detect_provider(self.host, self.provider))
49
+ object.__setattr__(self, "api_host", self.api_host or self.host)
50
+ object.__setattr__(self, "state_dir", Path(self.state_dir))
51
+
52
+ @property
53
+ def name(self) -> str:
54
+ return {"github.com": "github", "gitlab.com": "gitlab"}.get(self.host, self.host)
55
+
56
+ @property
57
+ def identity(self) -> str:
58
+ value = [self.provider, self.name, self.api_host, self.token]
59
+ return hashlib.sha256(
60
+ json.dumps(value, ensure_ascii=False, separators=(",", ":")).encode()
61
+ ).hexdigest()
62
+
63
+ def repository(self, path: str) -> Repository:
64
+ if not path or path.startswith("/") or path.endswith("/"):
65
+ raise ValueError("nonempty repository path is required")
66
+ return Repository(ForgeIdentity(self.name), path)
67
+
68
+ def client(self, path: str) -> Forge:
69
+ self.repository(path)
70
+ if self.provider == "github":
71
+ owner, separator, name = path.partition("/")
72
+ if not separator or not name or "/" in name:
73
+ raise ValueError("GitHub repository must be owner/name")
74
+ return GitHubForge(
75
+ self.api_host or self.host,
76
+ owner,
77
+ name,
78
+ self.token,
79
+ timeout=self.timeout,
80
+ state_dir=self.state_dir / "forge-http",
81
+ )
82
+ return GitLabForge(
83
+ self.api_host or self.host,
84
+ path,
85
+ self.token,
86
+ timeout=self.timeout,
87
+ state_dir=self.state_dir / "forge-http",
88
+ )
89
+
90
+ def pull_request_context(
91
+ self,
92
+ checkout: str | Path,
93
+ *,
94
+ source: str,
95
+ targets: tuple[str, ...] | None = None,
96
+ reader: Forge | None = None,
97
+ ) -> PullRequestContext:
98
+ observer = PullRequestObserver(
99
+ reader if reader is not None else self.client(source),
100
+ forge=self.name,
101
+ state_dir=self.state_dir,
102
+ connection=self.identity,
103
+ timeout=self.timeout,
104
+ )
105
+ return PullRequestContext(
106
+ observer,
107
+ checkout,
108
+ source=self.repository(source),
109
+ targets=tuple(
110
+ self.repository(path) for path in (targets if targets is not None else (source,))
111
+ ),
112
+ )
@@ -0,0 +1,99 @@
1
+ """Decode the Go validation engine's wire report; does not execute a second engine."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from collections.abc import Iterable
7
+ from pathlib import Path
8
+ from typing import Any, cast
9
+
10
+
11
+ def decode_validation_report(
12
+ payload: str, *, checkout: str | Path, checks: Iterable[str] = ("lint", "test")
13
+ ) -> dict[str, Any]:
14
+ """Validate one report against its checkout and requested checks.
15
+
16
+ @spec Failed checks remain valid reports. Malformed or incomplete protocol
17
+ evidence raises ValueError; consumers decide admission and report storage.
18
+ @link component://docs/validation.md
19
+ """
20
+ try:
21
+ return _decode(payload, str(checkout), checks)
22
+ except (KeyError, TypeError, AttributeError) as exc:
23
+ raise ValueError("malformed ValidationReport structure") from exc
24
+
25
+
26
+ def _decode(payload: str, repo: str, checks: Iterable[str]) -> dict[str, Any]:
27
+ data = json.loads(payload)
28
+ if not isinstance(data, dict) or data.get("schemaVersion") != 1:
29
+ raise ValueError("unsupported ValidationReport; upgrade repocli")
30
+ if data.get("checkout") != str(Path(repo).resolve()):
31
+ raise ValueError("ValidationReport checkout mismatch")
32
+ if any(type(data.get(key)) is not bool for key in ("inputsConsistent", "complete", "passed")):
33
+ raise ValueError("invalid ValidationReport outcome")
34
+ if not isinstance(data.get("snapshot"), str) or not data["snapshot"].startswith("sha256:"):
35
+ raise ValueError("ValidationReport snapshot missing")
36
+ if not isinstance(data.get("components"), list) or not isinstance(
37
+ data.get("diagnostics"), list
38
+ ):
39
+ raise ValueError("ValidationReport results missing")
40
+ selected = set(checks) - {"format"}
41
+ if not selected <= {"lint", "test"}:
42
+ raise ValueError("invalid requested validation checks")
43
+ seen: set[tuple[str, str]] = set()
44
+ coverage: dict[str, set[str]] = {}
45
+ for group in data["components"]:
46
+ root = group["component"]["root"]
47
+ if not isinstance(root, str) or not (Path(repo) / root).resolve().is_relative_to(
48
+ Path(repo).resolve()
49
+ ):
50
+ raise ValueError("ValidationReport Component escapes checkout")
51
+ for check in group["checks"]:
52
+ name = check["check"]
53
+ if name not in selected or (root, name) in seen:
54
+ raise ValueError("invalid or duplicate ValidationReport check")
55
+ seen.add((root, name))
56
+ sources = check.get("requestedComponents")
57
+ if (
58
+ not isinstance(sources, list)
59
+ or not sources
60
+ or any(not isinstance(source, str) for source in sources)
61
+ or len(set(sources)) != len(sources)
62
+ ):
63
+ raise ValueError("ValidationReport requestedComponents missing or duplicated")
64
+ for source in sources:
65
+ if not isinstance(source, str) or not (
66
+ Path(repo) / source
67
+ ).resolve().is_relative_to(Path(repo).resolve()):
68
+ raise ValueError("ValidationReport requested Component escapes checkout")
69
+ if name in coverage.setdefault(source, set()):
70
+ raise ValueError("duplicate requested Component check")
71
+ coverage[source].add(name)
72
+ if check["scope"] not in {"full", "focused", "skipped", "unavailable"} or check[
73
+ "status"
74
+ ] not in {"passed", "failed", "skipped", "unavailable", "cancelled", "uncertain"}:
75
+ raise ValueError("invalid validation check status or scope")
76
+ if type(check["reused"]) is not bool or not isinstance(check["witnesses"], dict):
77
+ raise ValueError("invalid validation evidence")
78
+ if any(
79
+ not isinstance(check.get(key), str)
80
+ for key in (
81
+ "checkedAt",
82
+ "inputFingerprint",
83
+ "environment",
84
+ "reason",
85
+ "reuseReason",
86
+ "stdout",
87
+ "stderr",
88
+ )
89
+ ):
90
+ raise ValueError("incomplete validation check result")
91
+ if any(
92
+ not isinstance(check.get(key), list)
93
+ or any(not isinstance(x, str) for x in check[key])
94
+ for key in ("command", "files")
95
+ ):
96
+ raise ValueError("invalid validation command or file list")
97
+ if any(checks != selected for checks in coverage.values()):
98
+ raise ValueError("ValidationReport omitted requested checks")
99
+ return cast(dict[str, Any], data)
@@ -221,3 +221,26 @@ def test_declared_manager_wins_and_ambiguous_managers_are_not_guessed(repo):
221
221
  (repo / "package.json").write_text('{"name":"ambiguous"}')
222
222
  with pytest.raises(ValueError, match="Ambiguous"):
223
223
  inspect_dependencies(repo)
224
+
225
+
226
+ def test_dependency_witnesses_follow_preparation_and_detect_stale_inputs(
227
+ repo, tmp_path, monkeypatch
228
+ ):
229
+ """@case A real preparation receipt enables reuse without caller filename knowledge."""
230
+ import hashlib
231
+
232
+ from repokit import dependency_witnesses
233
+
234
+ (repo / "go.mod").write_text("module example.com/fixture\n\ngo 1.23\n")
235
+ assert dependency_witnesses(repo) == {}
236
+ (repo / "go.mod").unlink()
237
+ node(repo)
238
+ assert dependency_witnesses(repo) is None
239
+ installer(tmp_path, monkeypatch)
240
+ assert prepare_dependencies(inspect_dependencies(repo)[0]).status == "ready"
241
+ witnesses = dependency_witnesses(repo)
242
+ receipt = repo / "node_modules/.repokit-dependencies.json"
243
+ assert witnesses[str(receipt)] == hashlib.sha256(receipt.read_bytes()).hexdigest()
244
+ assert set(witnesses) == {str(receipt), str(repo / "package.json"), str(repo / "bun.lock")}
245
+ (repo / "bun.lock").write_text("changed lock")
246
+ assert dependency_witnesses(repo) is None
@@ -0,0 +1,44 @@
1
+ import json
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ from repokit.forge import ForgeConnection, detect_provider
7
+ from repokit.forge.model import ForgeAuthError
8
+
9
+
10
+ def test_connection_identity_and_context_are_shared_with_typescript(repo, tmp_path):
11
+ """@case Credentials, provider and endpoint isolate caches; targets stay explicit."""
12
+ fixture = Path(__file__).resolve().parents[3] / "conformance/forge-connection.json"
13
+ for row in json.loads(fixture.read_text()):
14
+ connection = ForgeConnection(
15
+ host=row["host"],
16
+ provider=row["provider"],
17
+ api_host=row["apiHost"],
18
+ token=row["token"],
19
+ state_dir=tmp_path,
20
+ )
21
+ assert connection.name == row["name"]
22
+ assert connection.identity == row["identity"]
23
+ assert row["token"] not in repr(connection)
24
+ context = connection.pull_request_context(repo, source="owner/repo")
25
+ assert context.source == connection.repository("owner/repo")
26
+ assert context.targets == (context.source,)
27
+ assert context.observer.store.root == tmp_path
28
+ assert context.observer.reader.state_dir == tmp_path / "forge-http"
29
+ explicit = connection.pull_request_context(
30
+ repo, source="owner/repo", targets=("upstream/repo",)
31
+ )
32
+ assert explicit.targets == (connection.repository("upstream/repo"),)
33
+ with pytest.raises(ValueError):
34
+ connection.pull_request_context(repo, source="owner/repo", targets=())
35
+
36
+
37
+ def test_access_configuration_requires_credentials_and_valid_timeout():
38
+ assert detect_provider("github.example") == "github"
39
+ assert detect_provider("forge.example", "github") == "github"
40
+ assert detect_provider("forge.example") == "gitlab"
41
+ with pytest.raises(ForgeAuthError):
42
+ ForgeConnection("github.com", " ")
43
+ with pytest.raises(ValueError):
44
+ ForgeConnection("github.com", "fixture", timeout=0)
@@ -0,0 +1,88 @@
1
+ import copy
2
+ import json
3
+
4
+ import pytest
5
+
6
+ from repokit import decode_validation_report
7
+
8
+
9
+ def report(checkout):
10
+ checks = [
11
+ {
12
+ "check": name,
13
+ "requestedComponents": ["."],
14
+ "status": "passed",
15
+ "scope": "full",
16
+ "command": ["make", name],
17
+ "files": [],
18
+ "reason": "fixture",
19
+ "reused": False,
20
+ "reuseReason": "no previous check",
21
+ "checkedAt": "2026-11-02T00:00:00Z",
22
+ "inputFingerprint": "sha256:inputs",
23
+ "environment": "test",
24
+ "witnesses": {},
25
+ "stdout": "",
26
+ "stderr": "",
27
+ }
28
+ for name in ("lint", "test")
29
+ ]
30
+ return {
31
+ "schemaVersion": 1,
32
+ "checkout": str(checkout),
33
+ "snapshot": "sha256:snapshot",
34
+ "inputsConsistent": True,
35
+ "complete": True,
36
+ "passed": True,
37
+ "components": [{"component": {"root": "."}, "checks": checks}],
38
+ "diagnostics": [],
39
+ }
40
+
41
+
42
+ def test_failed_report_and_partial_fallback_groups_are_valid(tmp_path):
43
+ value = report(tmp_path)
44
+ lint, test = value["components"][0]["checks"]
45
+ lint["requestedComponents"] = test["requestedComponents"] = ["child"]
46
+ test["status"] = "failed"
47
+ value["passed"] = False
48
+ value["components"] = [
49
+ {"component": {"root": "."}, "checks": [lint]},
50
+ {"component": {"root": "child"}, "checks": [test]},
51
+ ]
52
+ assert (
53
+ decode_validation_report(
54
+ json.dumps(value), checkout=tmp_path, checks=("format", "lint", "test")
55
+ )
56
+ == value
57
+ )
58
+
59
+
60
+ @pytest.mark.parametrize(
61
+ "mutation", ["checkout", "schema", "missing", "duplicate", "escape", "shape", "bool"]
62
+ )
63
+ def test_invalid_report_is_not_validation_evidence(tmp_path, mutation):
64
+ value = report(tmp_path)
65
+ if mutation == "checkout":
66
+ value["checkout"] = str(tmp_path / "other")
67
+ elif mutation == "schema":
68
+ value["schemaVersion"] = 2
69
+ elif mutation == "missing":
70
+ value["components"][0]["checks"].pop()
71
+ elif mutation == "duplicate":
72
+ value["components"] *= 2
73
+ elif mutation == "escape":
74
+ value["components"][0]["component"]["root"] = "../outside"
75
+ elif mutation == "shape":
76
+ value["components"] = [None]
77
+ else:
78
+ value["complete"] = 1
79
+ with pytest.raises(ValueError):
80
+ decode_validation_report(json.dumps(value), checkout=tmp_path)
81
+
82
+
83
+ @pytest.mark.parametrize("sources", [[["child"]], [{"root": "."}], [1], [".", "."], []])
84
+ def test_requested_component_identity_must_be_unique_strings(tmp_path, sources):
85
+ value = copy.deepcopy(report(tmp_path))
86
+ value["components"][0]["checks"][0]["requestedComponents"] = sources
87
+ with pytest.raises(ValueError, match="requestedComponents"):
88
+ decode_validation_report(json.dumps(value), checkout=tmp_path)
@@ -17,7 +17,7 @@ wheels = [
17
17
 
18
18
  [[package]]
19
19
  name = "compforge-repokit"
20
- version = "0.16.0"
20
+ version = "0.17.0"
21
21
  source = { editable = "." }
22
22
  dependencies = [
23
23
  { name = "harness-common" },