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.
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/AGENTS.md +1 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/PKG-INFO +1 -1
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/pyproject.toml +1 -1
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/__init__.py +2 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/dependencies.py +19 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/__init__.py +2 -0
- compforge_repokit-0.17.0/repokit/forge/connection.py +112 -0
- compforge_repokit-0.17.0/repokit/validation_report.py +99 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_dependencies.py +23 -0
- compforge_repokit-0.17.0/tests/test_forge_connection.py +44 -0
- compforge_repokit-0.17.0/tests/test_validation_report.py +88 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/uv.lock +1 -1
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/.gitignore +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/Makefile +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/README.md +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/_inspect.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/_language.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/_layout.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/_process.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/budget.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/_http_state.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/_page.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/_rest.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/creation.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/github.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/gitlab.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/model.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/observation/__init__.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/observation/context.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/observation/model.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/observation/service.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/forge/observation/store.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/git/__init__.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/git/_catalog.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/git/_staging.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/git/index.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/git/operations.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/git/remote.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/git/stash.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/git/state.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/languages.json +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/model.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/py.typed +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/repository.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/snapshot.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/tree.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/repokit/validation.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/conftest.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_budget.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_checkout_contract.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_forge.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_forge_http.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_forge_repositories.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_fork.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_git_facts.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_git_helpers.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_git_index.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_inspect.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_metadata_contract.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_operations.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_pr_creation.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_pr_observation.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_process.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_rebase.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_repository_metadata.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_repository_queries.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_scalar_contract.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_snapshot_scopes.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_staging.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_stash.py +0 -0
- {compforge_repokit-0.16.0 → compforge_repokit-0.17.0}/tests/test_tree.py +0 -0
- {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 # 输入版本、整次调用预算与一致性观察
|
|
@@ -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)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|