pactkit-opencode 3.0.5__tar.gz → 4.0.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.
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/PKG-INFO +2 -2
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/pyproject.toml +2 -2
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/src/pactkit_opencode/deployer.py +23 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/src/pactkit_opencode.egg-info/PKG-INFO +2 -2
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/src/pactkit_opencode.egg-info/SOURCES.txt +5 -1
- pactkit_opencode-4.0.0/src/pactkit_opencode.egg-info/requires.txt +1 -0
- pactkit_opencode-4.0.0/tests/test_capability_contract.py +112 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_deploy_opencode_parity.py +17 -12
- pactkit_opencode-4.0.0/tests/test_host_body_snapshot.py +262 -0
- pactkit_opencode-4.0.0/tests/test_profile_contract.py +191 -0
- pactkit_opencode-4.0.0/tests/test_step8_declaration_search_producer.py +178 -0
- pactkit_opencode-4.0.0/tests/test_wire_format_contract.py +399 -0
- pactkit_opencode-3.0.5/src/pactkit_opencode.egg-info/requires.txt +0 -1
- pactkit_opencode-3.0.5/tests/test_host_body_snapshot.py +0 -126
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/README.md +0 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/setup.cfg +0 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/src/pactkit_opencode/__init__.py +0 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/src/pactkit_opencode/declarations.py +0 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/src/pactkit_opencode/enforcement.py +0 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/src/pactkit_opencode.egg-info/dependency_links.txt +0 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/src/pactkit_opencode.egg-info/entry_points.txt +0 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/src/pactkit_opencode.egg-info/top_level.txt +0 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_a1_config_recovery.py +0 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_a2_mcp_retired.py +0 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_bug035_opencode_dual_layer.py +0 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_gate_plugin_deploy.py +0 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_opencode_enforcement_channel.py +0 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_step6_self_sufficiency.py +0 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_story069_opencode_format.py +0 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_story070_opencode_compliance.py +0 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_story071_opencode_config_parity.py +0 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_story073_command_model_routing.py +0 -0
- {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_story_work_units.py +0 -0
|
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pactkit-opencode"
|
|
7
|
-
version = "
|
|
7
|
+
version = "4.0.0"
|
|
8
8
|
description = "PactKit adapter for OpenCode IDE"
|
|
9
9
|
requires-python = ">=3.10"
|
|
10
10
|
license = "MIT"
|
|
11
11
|
dependencies = [
|
|
12
|
-
"pactkit>=
|
|
12
|
+
"pactkit>=4.0.0,<4.1.0",
|
|
13
13
|
]
|
|
14
14
|
|
|
15
15
|
[project.entry-points."pactkit.deployers"]
|
|
@@ -28,6 +28,7 @@ from pactkit.generators.deployer import (
|
|
|
28
28
|
_deploy_rules,
|
|
29
29
|
_deploy_skills,
|
|
30
30
|
)
|
|
31
|
+
from pactkit.generators.spi import generate_config_if_missing
|
|
31
32
|
from pactkit_opencode import declarations
|
|
32
33
|
from pactkit.utils import atomic_write
|
|
33
34
|
|
|
@@ -264,6 +265,7 @@ class OpenCodeDeployer(DeployerBase):
|
|
|
264
265
|
|
|
265
266
|
if target is None:
|
|
266
267
|
self._generate_project_agents_md()
|
|
268
|
+
self._generate_project_config()
|
|
267
269
|
|
|
268
270
|
# --- OpenCode-specific helpers ---
|
|
269
271
|
|
|
@@ -288,6 +290,27 @@ class OpenCodeDeployer(DeployerBase):
|
|
|
288
290
|
record_deployed_file(opencode_root, plugin_path.relative_to(opencode_root).as_posix())
|
|
289
291
|
return 1
|
|
290
292
|
|
|
293
|
+
@staticmethod
|
|
294
|
+
def _generate_project_config():
|
|
295
|
+
"""Write this host's pactkit.yaml when the project has none (Step 8 R-③-1).
|
|
296
|
+
|
|
297
|
+
Before this existed, `.opencode/pactkit.yaml` was **declared** in
|
|
298
|
+
`profile.pactkit_yaml_path` and **searched** by every reader, but no
|
|
299
|
+
code path produced it — the three facts R-③-1 requires to agree did
|
|
300
|
+
not. It could still appear anyway: `init --format classic` in a project
|
|
301
|
+
carrying a `.opencode/` directory wrote CLASSIC's config there, because
|
|
302
|
+
the path fell to `resolve_pactkit_yaml_dir`'s directory-existence
|
|
303
|
+
fallback (measured 2026-09-26: `mkdir .opencode && pactkit init
|
|
304
|
+
--format classic` → `.opencode/pactkit.yaml` containing classic's).
|
|
305
|
+
|
|
306
|
+
`format="opencode"` closes both halves: this host produces its own
|
|
307
|
+
copy, and no other host can produce this one's.
|
|
308
|
+
"""
|
|
309
|
+
project_root = Path.cwd()
|
|
310
|
+
if project_root.resolve() == Path.home().resolve():
|
|
311
|
+
return
|
|
312
|
+
generate_config_if_missing(format="opencode", project_root=project_root)
|
|
313
|
+
|
|
291
314
|
@staticmethod
|
|
292
315
|
def _generate_project_agents_md():
|
|
293
316
|
"""Generate project-level AGENTS.md if it does not exist (STORY-slim-008 R4)."""
|
|
@@ -13,13 +13,17 @@ src/pactkit_opencode.egg-info/top_level.txt
|
|
|
13
13
|
tests/test_a1_config_recovery.py
|
|
14
14
|
tests/test_a2_mcp_retired.py
|
|
15
15
|
tests/test_bug035_opencode_dual_layer.py
|
|
16
|
+
tests/test_capability_contract.py
|
|
16
17
|
tests/test_deploy_opencode_parity.py
|
|
17
18
|
tests/test_gate_plugin_deploy.py
|
|
18
19
|
tests/test_host_body_snapshot.py
|
|
19
20
|
tests/test_opencode_enforcement_channel.py
|
|
21
|
+
tests/test_profile_contract.py
|
|
20
22
|
tests/test_step6_self_sufficiency.py
|
|
23
|
+
tests/test_step8_declaration_search_producer.py
|
|
21
24
|
tests/test_story069_opencode_format.py
|
|
22
25
|
tests/test_story070_opencode_compliance.py
|
|
23
26
|
tests/test_story071_opencode_config_parity.py
|
|
24
27
|
tests/test_story073_command_model_routing.py
|
|
25
|
-
tests/test_story_work_units.py
|
|
28
|
+
tests/test_story_work_units.py
|
|
29
|
+
tests/test_wire_format_contract.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
pactkit<4.1.0,>=4.0.0
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"""SPEC-4.0-22 Step 7 — 本宿主的 capability / deployment claim / pretool 契约。
|
|
2
|
+
|
|
3
|
+
**为什么这份测试在本仓。** 这三张表(能力声明、部署声明、pre-tool 机制名)
|
|
4
|
+
自 Step 6/7 起由本 adapter 自带并经 entry-point 注册。core 侧只保留它随包发布
|
|
5
|
+
的 `classic`。所以「本宿主能做什么、怎么执行、pre-tool 机制叫什么」的断言属于
|
|
6
|
+
**本仓**——生产里的事实归谁,断言就归谁。
|
|
7
|
+
|
|
8
|
+
core 侧对应断言(`test_spec40_22_capability_registry.py` 的宿主值行)已随之删除。
|
|
9
|
+
**是搬迁,不是复制**:两处各留一份就是给同一个事实造两个真相源。
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from pactkit.capability import resolve_capabilities
|
|
15
|
+
from pactkit.generators.deploy_base import _ensure_entry_point_deployers
|
|
16
|
+
from pactkit.profiles import registered_adapters
|
|
17
|
+
|
|
18
|
+
HOST = "opencode"
|
|
19
|
+
PRETOOL = 'tool.execute.before (plugin; veto by throwing)'
|
|
20
|
+
CLAIM = ('portable', 'adapter_native_command_facade')
|
|
21
|
+
ENV_CAPABLE = {'opencode.json'}
|
|
22
|
+
PROTECTED = set()
|
|
23
|
+
CONDITIONAL = set()
|
|
24
|
+
PRESENT = ['git_hook', 'native_command', 'native_skill', 'pre_tool_hook', 'project_instruction', 'shell_execution']
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _registration():
|
|
28
|
+
_ensure_entry_point_deployers()
|
|
29
|
+
return registered_adapters()[HOST]
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class TestTheDeclarationReachesTheRegistry:
|
|
33
|
+
def test_the_registry_carries_this_hosts_claim(self):
|
|
34
|
+
claim = _registration().deployment_claim
|
|
35
|
+
assert (claim.execution_mode, claim.verification_source) == CLAIM
|
|
36
|
+
|
|
37
|
+
def test_the_registry_carries_this_hosts_pretool_mechanism(self):
|
|
38
|
+
assert _registration().pretool_surface == PRETOOL
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class TestTheCapabilityDeclaration:
|
|
42
|
+
def test_the_present_set_is_exactly_this(self):
|
|
43
|
+
"""**逐项**钉住,不是只钉个数——多一项少一项都要红。
|
|
44
|
+
|
|
45
|
+
迁移前基线是 6 项;这条断言的是同一批名字,所以它既防漏声明,
|
|
46
|
+
也防「为了让表看起来完整」而虚报一项。
|
|
47
|
+
"""
|
|
48
|
+
resolved = resolve_capabilities(HOST)
|
|
49
|
+
assert resolved.known
|
|
50
|
+
present = sorted(n for n, e in resolved.entries.items() if e.present)
|
|
51
|
+
assert present == PRESENT
|
|
52
|
+
|
|
53
|
+
def test_every_declared_entry_carries_evidence(self):
|
|
54
|
+
for name, entry in resolve_capabilities(HOST).entries.items():
|
|
55
|
+
assert entry.evidence, f"{name} 没有证据来源——那就是一个断言,不是声明"
|
|
56
|
+
|
|
57
|
+
def test_the_declaration_is_not_empty(self):
|
|
58
|
+
assert _registration().capabilities, (
|
|
59
|
+
"本宿主没有声明任何能力——registry 会把每一项都填成 Degraded"
|
|
60
|
+
)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class TestTheContractHasTeeth:
|
|
64
|
+
"""反证:改坏本仓的声明,上面必须有测试变红。"""
|
|
65
|
+
|
|
66
|
+
def test_mutating_the_claim_moves_the_answer(self):
|
|
67
|
+
import dataclasses
|
|
68
|
+
|
|
69
|
+
from pactkit.profiles import register_adapter
|
|
70
|
+
import pactkit_opencode.declarations as decl
|
|
71
|
+
|
|
72
|
+
entry = _registration()
|
|
73
|
+
broken = dataclasses.replace(
|
|
74
|
+
entry.deployment_claim, verification_source="mutated"
|
|
75
|
+
)
|
|
76
|
+
register_adapter(HOST, deployer=entry.deployer, profile=entry.profile,
|
|
77
|
+
deployment_claim=broken)
|
|
78
|
+
try:
|
|
79
|
+
assert _registration().deployment_claim.verification_source == "mutated", (
|
|
80
|
+
"改掉本仓的声明后 registry 没变——core 侧还有第二份真相源"
|
|
81
|
+
)
|
|
82
|
+
finally:
|
|
83
|
+
register_adapter(HOST, deployer=entry.deployer, profile=entry.profile,
|
|
84
|
+
deployment_claim=decl.DEPLOYMENT_CLAIM)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# ---------------------------------------------------------------------------
|
|
88
|
+
# 安全事实(SPEC-4.0-22 R8 / Step 7)
|
|
89
|
+
# ---------------------------------------------------------------------------
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
class TestTheSecurityFacts:
|
|
93
|
+
"""这三类的定义域是 **PactKit gate-relative**:`env` 能到达 gate 进程的文件、
|
|
94
|
+
gate 不许任何人编辑的文件、移除 gate 条目算篡改的文件。
|
|
95
|
+
|
|
96
|
+
所以它们是**本宿主的声明**——core 侧那张跨宿主表已随 Step 7 删除,这里的
|
|
97
|
+
断言跟事实走。
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def test_the_declared_files_are_exactly_these(self):
|
|
101
|
+
s = _registration().security
|
|
102
|
+
assert set(s.env_capable.files) == ENV_CAPABLE
|
|
103
|
+
assert set(s.protected.files) == PROTECTED
|
|
104
|
+
assert set(s.conditional.files) == CONDITIONAL
|
|
105
|
+
|
|
106
|
+
def test_the_declaration_is_complete(self):
|
|
107
|
+
"""`missing`(宿主没说话)与 `empty`(宿主说了它没有)是两件事。
|
|
108
|
+
|
|
109
|
+
声明不完整时,需要 tamper_guard 授权的写操作会 fail closed——那是
|
|
110
|
+
R8 的设计,不是缺陷。所以「完整」本身是要被钉住的事实。
|
|
111
|
+
"""
|
|
112
|
+
assert _registration().security.complete
|
|
@@ -271,18 +271,23 @@ class TestGenerateConfigIfMissing:
|
|
|
271
271
|
yaml_path = tmp_path / ".claude" / "pactkit.yaml"
|
|
272
272
|
assert yaml_path.exists()
|
|
273
273
|
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
274
|
+
# `test_no_format_autodetects` stood here. It asserted that with no `format`
|
|
275
|
+
# argument, Core's generator followed whichever candidate directory existed
|
|
276
|
+
# — "existing behavior preserved", its docstring said. SPEC-4.0-22 Step 8
|
|
277
|
+
# deleted that behaviour (owner 2026-09-26: *reading may discover existing
|
|
278
|
+
# layers; writing must know which format it is writing for*).
|
|
279
|
+
#
|
|
280
|
+
# The contract it holds now — `format=None` resolves to classic's path and
|
|
281
|
+
# consults no directory — is **Core's**, so it is asserted in Core
|
|
282
|
+
# (`tests/unit/test_profiles.py::test_no_format_is_classic_regardless_of_which_dirs_exist`,
|
|
283
|
+
# `test_spec40_22_step8_config_generation.py`). A copy here would buy no
|
|
284
|
+
# coverage and would couple this adapter's CI to Core's release schedule:
|
|
285
|
+
# this repo installs Core from PyPI, so such a copy could only go green once
|
|
286
|
+
# the Step 8 Core was published — a red that says nothing about this repo.
|
|
287
|
+
#
|
|
288
|
+
# What this adapter owns is that IT never relies on that default. See
|
|
289
|
+
# `test_step8_declaration_search_producer.py`: it drives the real `deploy()`
|
|
290
|
+
# and asserts the config lands at this host's own declared path.
|
|
286
291
|
|
|
287
292
|
def test_no_write_when_yaml_exists(self, tmp_path):
|
|
288
293
|
from pactkit.generators.deployer import _generate_config_if_missing
|
|
@@ -0,0 +1,262 @@
|
|
|
1
|
+
"""This host's deployed prompt bodies, pinned against a golden (R7/AC10).
|
|
2
|
+
|
|
3
|
+
2026-09-12 review (83/100, gap 2): the zero-credential acceptance split
|
|
4
|
+
left adapter-host body comparison out of CI — the core workflow compares
|
|
5
|
+
classic only. This suite pins the OPENCODE deployment's prompt bodies:
|
|
6
|
+
|
|
7
|
+
- digest drift (a rule edited or deleted inside a prompt body) fails,
|
|
8
|
+
naming the file;
|
|
9
|
+
- a core version mismatch fails with an explicit action — regenerate the
|
|
10
|
+
golden against the new core, or publish the matching core release
|
|
11
|
+
(PENDING-COMPATIBLE-CORE, see the spec R8 release-window semantics);
|
|
12
|
+
- a probe test deploys, edits one body in place, and proves the SAME
|
|
13
|
+
comparison logic flags it (删改必被检出, not vacuous).
|
|
14
|
+
|
|
15
|
+
**Two kinds of artifact, two kinds of assertion** (SPEC-4.0-22 Step 7).
|
|
16
|
+
A prompt body is stable content, so a snapshot is the right instrument. A
|
|
17
|
+
*deployed manifest* and the *gate plugin* are a function of **which adapters
|
|
18
|
+
are installed**, so snapshotting them would freeze "whatever happened to be
|
|
19
|
+
installed on the machine that regenerated the golden" — the golden would then
|
|
20
|
+
be reproducible only there, and CI (which installs core + this adapter alone)
|
|
21
|
+
would go red on a change that is not a regression.
|
|
22
|
+
|
|
23
|
+
Measured 2026-09-26: a `core+opencode` deploy and an all-four-adapters deploy
|
|
24
|
+
differ in **exactly two files** — `.pactkit-deployed.json` and
|
|
25
|
+
`plugins/pactkit-gate.js` — and in nothing else. Those two are excluded from
|
|
26
|
+
the golden and covered by `TestEnvironmentDerivedArtifactsAreConsistent`
|
|
27
|
+
instead: their bytes may differ between environments, but each environment
|
|
28
|
+
must be internally consistent.
|
|
29
|
+
|
|
30
|
+
Before Step 7 the distinction was invisible. The transitional seed made
|
|
31
|
+
`PACTKIT_YAML_CANDIDATES` a constant, so the plugin — and therefore the
|
|
32
|
+
manifest that records its hash — was identical everywhere. The golden was
|
|
33
|
+
reproducible for the wrong reason, and removing the seed is what made the
|
|
34
|
+
instrument's real boundary visible.
|
|
35
|
+
|
|
36
|
+
Regenerate with: tests/scripts/refresh_host_body_golden.py
|
|
37
|
+
"""
|
|
38
|
+
|
|
39
|
+
from __future__ import annotations
|
|
40
|
+
|
|
41
|
+
import hashlib
|
|
42
|
+
import json
|
|
43
|
+
import re
|
|
44
|
+
import subprocess
|
|
45
|
+
import sys
|
|
46
|
+
from pathlib import Path
|
|
47
|
+
|
|
48
|
+
import pytest
|
|
49
|
+
|
|
50
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent / "scripts"))
|
|
51
|
+
|
|
52
|
+
from refresh_host_body_golden import ( # noqa: E402
|
|
53
|
+
DEPLOY_SUFFIXES,
|
|
54
|
+
ENVIRONMENT_DERIVED,
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
HOST = "opencode"
|
|
58
|
+
GOLDEN = Path(__file__).parent / "fixtures" / "host_body_golden.json"
|
|
59
|
+
|
|
60
|
+
#: The plugin's marker list, as emitted by
|
|
61
|
+
#: `pactkit.generators.opencode_plugin.render_plugin_source`.
|
|
62
|
+
_MARKERS_RE = re.compile(r"const PROJECT_MARKERS = (\[.*?\])\n", re.S)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _core_version() -> str:
|
|
66
|
+
import importlib.metadata as m
|
|
67
|
+
|
|
68
|
+
return m.version("pactkit")
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _normalize(content: str, deploy_root: str) -> str:
|
|
72
|
+
"""Strip only the deterministic environment difference: the deploy root
|
|
73
|
+
path baked into rendered prompts. Rule text stays — normalizing it would
|
|
74
|
+
swallow the drift this suite exists to catch."""
|
|
75
|
+
return content.replace(deploy_root, "<DEPLOY_ROOT>")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _deploy(tmp_path: Path) -> tuple[Path, subprocess.CompletedProcess]:
|
|
79
|
+
"""Deploy this host into `tmp_path`; returns (deploy root, process)."""
|
|
80
|
+
home = tmp_path / "home"
|
|
81
|
+
target = tmp_path / "deploy"
|
|
82
|
+
home.mkdir(exist_ok=True)
|
|
83
|
+
proc = subprocess.run(
|
|
84
|
+
[sys.executable, "-m", "pactkit", "init", "--format", HOST, "--target", str(target)],
|
|
85
|
+
capture_output=True, text=True, timeout=180,
|
|
86
|
+
env={"PATH": "/usr/bin:/bin", "HOME": str(home), "COLUMNS": "200"},
|
|
87
|
+
)
|
|
88
|
+
return target, proc
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _deployed_digests(tmp_path: Path) -> tuple[dict[str, str] | None, subprocess.CompletedProcess]:
|
|
92
|
+
target, proc = _deploy(tmp_path)
|
|
93
|
+
if proc.returncode != 0:
|
|
94
|
+
return None, proc
|
|
95
|
+
digests: dict[str, str] = {}
|
|
96
|
+
for path in sorted(target.rglob("*")):
|
|
97
|
+
rel = path.relative_to(target).as_posix()
|
|
98
|
+
if not path.is_file() or path.suffix not in DEPLOY_SUFFIXES:
|
|
99
|
+
continue
|
|
100
|
+
if rel in ENVIRONMENT_DERIVED:
|
|
101
|
+
continue
|
|
102
|
+
body = _normalize(path.read_text(encoding="utf-8"), str(target))
|
|
103
|
+
digests[rel] = hashlib.sha256(body.encode("utf-8")).hexdigest()
|
|
104
|
+
return digests, proc
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _assert_no_drift(golden_digests: dict[str, str], live: dict[str, str]) -> None:
|
|
108
|
+
"""The one comparison both the golden test and the tamper probe use."""
|
|
109
|
+
changed = sorted(
|
|
110
|
+
k for k in set(golden_digests) & set(live) if golden_digests[k] != live[k]
|
|
111
|
+
)
|
|
112
|
+
removed = sorted(set(golden_digests) - set(live))
|
|
113
|
+
added = sorted(set(live) - set(golden_digests))
|
|
114
|
+
assert not (changed or removed or added), (
|
|
115
|
+
f"prompt body drift: changed={changed} removed={removed} added={added} "
|
|
116
|
+
f"— if the core prompts changed, regenerate the golden "
|
|
117
|
+
f"(tests/scripts/refresh_host_body_golden.py) or publish the matching "
|
|
118
|
+
f"core release first (PENDING-COMPATIBLE-CORE)"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _markers_from(source: str) -> list[str]:
|
|
123
|
+
m = _MARKERS_RE.search(source)
|
|
124
|
+
assert m, "the deployed plugin has no `const PROJECT_MARKERS = [...]` block"
|
|
125
|
+
return json.loads(m.group(1))
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TestHostBodySnapshot:
|
|
129
|
+
def test_bodies_match_the_golden(self, tmp_path):
|
|
130
|
+
golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
|
|
131
|
+
core_v = _core_version()
|
|
132
|
+
if golden["core_version"] != core_v:
|
|
133
|
+
pytest.fail(
|
|
134
|
+
f"PENDING-COMPATIBLE-CORE: the {HOST} golden was generated "
|
|
135
|
+
f"against core {golden['core_version']}, this environment has "
|
|
136
|
+
f"{core_v}. Either regenerate it after the core change "
|
|
137
|
+
f"(tests/scripts/refresh_host_body_golden.py) or publish the "
|
|
138
|
+
f"matching core release first (spec R8 release-window semantics)."
|
|
139
|
+
)
|
|
140
|
+
live, proc = _deployed_digests(tmp_path)
|
|
141
|
+
assert live is not None, (
|
|
142
|
+
f"{HOST} deploy failed: {proc.stdout[-300:]}{proc.stderr[-300:]}"
|
|
143
|
+
)
|
|
144
|
+
_assert_no_drift(golden["body_digests"], live)
|
|
145
|
+
|
|
146
|
+
def test_the_golden_pins_no_environment_derived_artifact(self):
|
|
147
|
+
"""**边界断言**:golden 里不许出现环境相关产物。
|
|
148
|
+
|
|
149
|
+
没有这条,下一个再生的人在装了四个 adapter 的机器上跑一次脚本,就会把
|
|
150
|
+
本机环境冻回 golden 里,而 CI(core+self)再次变红——正是这次修的形态。
|
|
151
|
+
"""
|
|
152
|
+
golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
|
|
153
|
+
leaked = sorted(set(golden["body_digests"]) & set(ENVIRONMENT_DERIVED))
|
|
154
|
+
assert not leaked, (
|
|
155
|
+
f"the golden pins environment-derived artifacts: {leaked}. Their "
|
|
156
|
+
f"bytes are a function of which adapters are installed; pinning "
|
|
157
|
+
f"them makes the golden reproducible only on the machine that "
|
|
158
|
+
f"generated it. See TestEnvironmentDerivedArtifactsAreConsistent."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
def test_a_body_edit_is_caught_by_the_same_comparison(self, tmp_path):
|
|
162
|
+
"""Non-vacuity, end to end: deploy, edit one prompt body in place,
|
|
163
|
+
and the SAME comparison that guards the golden must flag it."""
|
|
164
|
+
golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
|
|
165
|
+
live, proc = _deployed_digests(tmp_path)
|
|
166
|
+
assert live is not None, f"{HOST} deploy failed: {proc.stderr[-300:]}"
|
|
167
|
+
target = tmp_path / "deploy"
|
|
168
|
+
# Pick a real deployed prompt body and delete one rule line from it.
|
|
169
|
+
victims = sorted(
|
|
170
|
+
p for p in target.rglob("*.md") if p.relative_to(target).as_posix() in golden["body_digests"]
|
|
171
|
+
)
|
|
172
|
+
assert victims, "no deployed .md body found to tamper with"
|
|
173
|
+
victim = victims[0]
|
|
174
|
+
rel = victim.relative_to(target).as_posix()
|
|
175
|
+
body = victim.read_text(encoding="utf-8")
|
|
176
|
+
victim.write_text(body + "\n- deleted in a review\n", encoding="utf-8")
|
|
177
|
+
|
|
178
|
+
tampered = {
|
|
179
|
+
p.relative_to(target).as_posix(): hashlib.sha256(
|
|
180
|
+
_normalize(p.read_text(encoding="utf-8"), str(target)).encode("utf-8")
|
|
181
|
+
).hexdigest()
|
|
182
|
+
for p in sorted(target.rglob("*"))
|
|
183
|
+
if p.is_file()
|
|
184
|
+
and p.suffix in DEPLOY_SUFFIXES
|
|
185
|
+
and p.relative_to(target).as_posix() not in ENVIRONMENT_DERIVED
|
|
186
|
+
}
|
|
187
|
+
with pytest.raises(AssertionError, match=rel):
|
|
188
|
+
_assert_no_drift(golden["body_digests"], tampered)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class TestEnvironmentDerivedArtifactsAreConsistent:
|
|
192
|
+
"""环境相关产物:字节**可以**随已安装 adapter 集合变化,但必须自洽。
|
|
193
|
+
|
|
194
|
+
SPEC-4.0-22 Step 7 之后 `PACTKIT_YAML_CANDIDATES` 是 registry 的活视图,
|
|
195
|
+
于是插件的 `PROJECT_MARKERS`(以及 manifest 里记的插件摘要)都随环境变化。
|
|
196
|
+
这类产物的正确仪器是 **invariant**,不是 snapshot——snapshot 会把"本机装了
|
|
197
|
+
什么"冻成期望值。
|
|
198
|
+
|
|
199
|
+
这三条在 `core+opencode` 与"四个 adapter 全装"两个环境下**都**成立,且各自
|
|
200
|
+
断言各自环境的值。
|
|
201
|
+
"""
|
|
202
|
+
|
|
203
|
+
def test_the_plugin_carries_the_current_candidate_list(self, tmp_path):
|
|
204
|
+
"""插件的 `PROJECT_MARKERS` 必须等于**当前**的候选清单。
|
|
205
|
+
|
|
206
|
+
这是独立于生成器的那一半:它读**部署出来的 JS** 与**活的 registry**,
|
|
207
|
+
不问 `render_plugin_source()`。生成器若改了 marker 的来源,这条会红。
|
|
208
|
+
|
|
209
|
+
`_ensure_entry_point_deployers()` 必须先跑:`PACTKIT_YAML_CANDIDATES` 是
|
|
210
|
+
registry 的**活视图**,但本测试进程只 import 了本宿主(conftest),别的
|
|
211
|
+
adapter 要靠 entry-point discovery 才注册;而子进程里的 CLI 会触发它。
|
|
212
|
+
不先跑,两边比的就是**两个不同的 registry**。
|
|
213
|
+
"""
|
|
214
|
+
from pactkit.generators.deploy_base import _ensure_entry_point_deployers
|
|
215
|
+
from pactkit.profiles import PACTKIT_YAML_CANDIDATES, SHARED_PACTKIT_YAML
|
|
216
|
+
|
|
217
|
+
_ensure_entry_point_deployers()
|
|
218
|
+
target, proc = _deploy(tmp_path)
|
|
219
|
+
assert proc.returncode == 0, proc.stderr[-300:]
|
|
220
|
+
source = (target / "plugins" / "pactkit-gate.js").read_text(encoding="utf-8")
|
|
221
|
+
|
|
222
|
+
expected = [*PACTKIT_YAML_CANDIDATES, SHARED_PACTKIT_YAML]
|
|
223
|
+
markers = _markers_from(source)
|
|
224
|
+
assert markers == expected, (
|
|
225
|
+
f"the plugin's marker list is not the current candidate list: "
|
|
226
|
+
f"{markers} != {expected}"
|
|
227
|
+
)
|
|
228
|
+
assert markers, "anti-vacuity: the marker list is empty"
|
|
229
|
+
|
|
230
|
+
def test_the_manifest_records_the_plugin_it_wrote(self, tmp_path):
|
|
231
|
+
"""manifest 记的插件 sha256 必须等于**实际写出的**插件字节。"""
|
|
232
|
+
target, proc = _deploy(tmp_path)
|
|
233
|
+
assert proc.returncode == 0, proc.stderr[-300:]
|
|
234
|
+
manifest = json.loads(
|
|
235
|
+
(target / ".pactkit-deployed.json").read_text(encoding="utf-8")
|
|
236
|
+
)
|
|
237
|
+
rel = "plugins/pactkit-gate.js"
|
|
238
|
+
assert rel in manifest["files"], (
|
|
239
|
+
f"the plugin is not recorded in the manifest: {sorted(manifest['files'])}"
|
|
240
|
+
)
|
|
241
|
+
actual = hashlib.sha256((target / rel).read_bytes()).hexdigest()
|
|
242
|
+
assert manifest["files"][rel] == actual, (
|
|
243
|
+
"the manifest's plugin digest is not the plugin on disk — a stale "
|
|
244
|
+
"hash would make the deploy look verified while the file changed"
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
def test_every_recorded_file_is_on_disk_with_that_digest(self, tmp_path):
|
|
248
|
+
"""`files` map 自洽:每一条都在盘上,且摘要相符。"""
|
|
249
|
+
target, proc = _deploy(tmp_path)
|
|
250
|
+
assert proc.returncode == 0, proc.stderr[-300:]
|
|
251
|
+
manifest = json.loads(
|
|
252
|
+
(target / ".pactkit-deployed.json").read_text(encoding="utf-8")
|
|
253
|
+
)
|
|
254
|
+
files = manifest["files"]
|
|
255
|
+
assert files, "anti-vacuity: the manifest records no files"
|
|
256
|
+
wrong = sorted(
|
|
257
|
+
rel
|
|
258
|
+
for rel, digest in files.items()
|
|
259
|
+
if not (target / rel).is_file()
|
|
260
|
+
or hashlib.sha256((target / rel).read_bytes()).hexdigest() != digest
|
|
261
|
+
)
|
|
262
|
+
assert not wrong, f"the manifest disagrees with the disk for: {wrong}"
|