pactkit-opencode 3.0.5__tar.gz → 3.1.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 (32) hide show
  1. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/PKG-INFO +2 -2
  2. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/pyproject.toml +2 -2
  3. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/src/pactkit_opencode.egg-info/PKG-INFO +2 -2
  4. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/src/pactkit_opencode.egg-info/SOURCES.txt +4 -1
  5. pactkit_opencode-3.1.0/src/pactkit_opencode.egg-info/requires.txt +1 -0
  6. pactkit_opencode-3.1.0/tests/test_capability_contract.py +112 -0
  7. pactkit_opencode-3.1.0/tests/test_host_body_snapshot.py +262 -0
  8. pactkit_opencode-3.1.0/tests/test_profile_contract.py +191 -0
  9. pactkit_opencode-3.1.0/tests/test_wire_format_contract.py +399 -0
  10. pactkit_opencode-3.0.5/src/pactkit_opencode.egg-info/requires.txt +0 -1
  11. pactkit_opencode-3.0.5/tests/test_host_body_snapshot.py +0 -126
  12. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/README.md +0 -0
  13. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/setup.cfg +0 -0
  14. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/src/pactkit_opencode/__init__.py +0 -0
  15. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/src/pactkit_opencode/declarations.py +0 -0
  16. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/src/pactkit_opencode/deployer.py +0 -0
  17. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/src/pactkit_opencode/enforcement.py +0 -0
  18. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/src/pactkit_opencode.egg-info/dependency_links.txt +0 -0
  19. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/src/pactkit_opencode.egg-info/entry_points.txt +0 -0
  20. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/src/pactkit_opencode.egg-info/top_level.txt +0 -0
  21. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/tests/test_a1_config_recovery.py +0 -0
  22. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/tests/test_a2_mcp_retired.py +0 -0
  23. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/tests/test_bug035_opencode_dual_layer.py +0 -0
  24. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/tests/test_deploy_opencode_parity.py +0 -0
  25. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/tests/test_gate_plugin_deploy.py +0 -0
  26. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/tests/test_opencode_enforcement_channel.py +0 -0
  27. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/tests/test_step6_self_sufficiency.py +0 -0
  28. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/tests/test_story069_opencode_format.py +0 -0
  29. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/tests/test_story070_opencode_compliance.py +0 -0
  30. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/tests/test_story071_opencode_config_parity.py +0 -0
  31. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/tests/test_story073_command_model_routing.py +0 -0
  32. {pactkit_opencode-3.0.5 → pactkit_opencode-3.1.0}/tests/test_story_work_units.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pactkit-opencode
3
- Version: 3.0.5
3
+ Version: 3.1.0
4
4
  Summary: PactKit adapter for OpenCode IDE
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
7
- Requires-Dist: pactkit<3.1.0,>=3.0.1
7
+ Requires-Dist: pactkit<3.2.0,>=3.0.1
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pactkit-opencode"
7
- version = "3.0.5"
7
+ version = "3.1.0"
8
8
  description = "PactKit adapter for OpenCode IDE"
9
9
  requires-python = ">=3.10"
10
10
  license = "MIT"
11
11
  dependencies = [
12
- "pactkit>=3.0.1,<3.1.0",
12
+ "pactkit>=3.0.1,<3.2.0",
13
13
  ]
14
14
 
15
15
  [project.entry-points."pactkit.deployers"]
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pactkit-opencode
3
- Version: 3.0.5
3
+ Version: 3.1.0
4
4
  Summary: PactKit adapter for OpenCode IDE
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
7
- Requires-Dist: pactkit<3.1.0,>=3.0.1
7
+ Requires-Dist: pactkit<3.2.0,>=3.0.1
@@ -13,13 +13,16 @@ 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
21
23
  tests/test_story069_opencode_format.py
22
24
  tests/test_story070_opencode_compliance.py
23
25
  tests/test_story071_opencode_config_parity.py
24
26
  tests/test_story073_command_model_routing.py
25
- tests/test_story_work_units.py
27
+ tests/test_story_work_units.py
28
+ tests/test_wire_format_contract.py
@@ -0,0 +1 @@
1
+ pactkit<3.2.0,>=3.0.1
@@ -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
@@ -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}"
@@ -0,0 +1,191 @@
1
+ """SPEC-4.0-22 Step 7 — 本宿主的 profile 契约。
2
+
3
+ **为什么这份测试在本仓。** opencode 的 `FormatProfile` 自 Step 6/7 起由本 adapter
4
+ 自带并经 entry-point 注册;core 侧只保留它随包发布的 `classic`。所以「opencode 的
5
+ 全局配置目录是什么」「它的命令以什么形态落盘」这类断言属于**本仓**——生产里的
6
+ 事实在谁那里,断言就在谁那里。
7
+
8
+ core 侧对应的 core 侧对应的 profile 值断言
9
+ 已随之删除。**是搬迁,不是复制**:两处各留一份,就是给同一个事实造两个真相源,
10
+ 而漂移的那天两边都会绿。
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import cast
16
+
17
+ import pytest
18
+
19
+ from pactkit.profiles import (
20
+ CLIPolicy,
21
+ FormatProfile,
22
+ get_profile,
23
+ )
24
+ from pactkit_opencode.declarations import PROFILE
25
+
26
+ SPEC = cast(FormatProfile, PROFILE)
27
+
28
+
29
+ class TestTheProfileIsReachable:
30
+ """注册真的发生,且生产读路径(registry)能拿到它。"""
31
+
32
+ def test_it_is_registered(self):
33
+ from pactkit.generators.deploy_base import _ensure_entry_point_deployers
34
+
35
+ _ensure_entry_point_deployers()
36
+ assert get_profile("opencode").name == "opencode"
37
+
38
+ def test_get_profile_returns_the_same_object(self):
39
+ from pactkit.generators.deploy_base import _ensure_entry_point_deployers
40
+ from pactkit.profiles import get_registration
41
+
42
+ _ensure_entry_point_deployers()
43
+ assert get_registration("opencode").profile is SPEC, (
44
+ "registry 里那份不是本仓声明的这个对象——说明还有第二个真相源"
45
+ )
46
+
47
+ def test_it_is_a_format_profile(self):
48
+ assert isinstance(SPEC, FormatProfile)
49
+
50
+ def test_it_is_frozen(self):
51
+ with pytest.raises(AttributeError):
52
+ SPEC.name = "modified"
53
+
54
+
55
+ class TestThePathFacts:
56
+ """本宿主的目录布局——**实测值**,不是从 core 抄来的默认值。"""
57
+
58
+ @pytest.mark.parametrize(
59
+ "field,expected",
60
+ [
61
+ ("name", "opencode"),
62
+ ("display_name", "OpenCode"),
63
+ ("global_config_dir", "~/.config/opencode"),
64
+ ("project_config_dir", ".opencode"),
65
+ ("skills_dir", "~/.config/opencode/skills"),
66
+ ("agents_dir", "~/.config/opencode/agents"),
67
+ ("rules_dir", "~/.config/opencode/rules"),
68
+ ("project_instructions_file", "AGENTS.md"),
69
+ ("pactkit_yaml_path", ".opencode/pactkit.yaml"),
70
+ ("agent_format", "md"),
71
+ ("rules_import_style", "inline"),
72
+ ("skills_path_var", "~/.config/opencode/skills"),
73
+ ],
74
+ )
75
+ def test_field(self, field, expected):
76
+ assert getattr(SPEC, field) == expected
77
+
78
+
79
+ class TestTheMeasuredLayoutFact:
80
+ def test_commands_deploy_flat(self):
81
+ """**实测**(2026-09-22):opencode 是唯一命令**平铺**落盘的宿主。
82
+
83
+ `commands_dir` 恰好也指向那里,所以两者容易混为一谈——这正是这一条要
84
+ 钉死的:布局事实是 `command_deploy_form`,不是目录名。
85
+ """
86
+ assert SPEC.command_deploy_form == "flat", (
87
+ "opencode 的命令平铺落盘;改成 `skill`/`prompt` 会让产物落错地方"
88
+ )
89
+
90
+
91
+ class TestTheExcludedFields:
92
+ """哪些字段在本宿主上没有意义——**这是本宿主的判断**,不是 core 的默认。"""
93
+
94
+ def test_claude_only_agent_fields_are_excluded(self):
95
+ for field in ("permissionMode", "memory", "skills"):
96
+ assert field in SPEC.excluded_agent_fields, (
97
+ f"{field} 没被排除——它会作为无意义的字段写进 agent 文件"
98
+ )
99
+
100
+ def test_no_command_is_excluded(self):
101
+ assert SPEC.excluded_commands == frozenset()
102
+
103
+
104
+ class TestTheCLIPolicy:
105
+ def test_opencode_requires_the_pactkit_cli(self):
106
+ assert SPEC.cli_policy is CLIPolicy.REQUIRED
107
+
108
+ def test_has_pactkit_cli_is_derived_from_the_policy(self):
109
+ assert SPEC.has_pactkit_cli is True
110
+
111
+
112
+ class TestTheDeclaredDialects:
113
+ """opencode **调共享 writer**,所以这两个字段有真消费者,必须声明。
114
+
115
+ 在 BUG-slim-20260923aeb5e4807a2b 之前它们是「非 classic」隐含推出来的——
116
+ 恰好对这两个调用者成立,所以一直没被发现。
117
+ """
118
+
119
+ @pytest.mark.parametrize("field", ["agent_dialect", "command_dialect"])
120
+ def test_it_declares_opencode(self, field):
121
+ assert getattr(SPEC, field) == "opencode", (
122
+ f"{field} 没有声明——共享 writer 会去借别的宿主的方言"
123
+ )
124
+
125
+
126
+ class TestTheLiveViewsSeeIt:
127
+ """core 的活视图(`VALID_FORMATS` / `PACTKIT_YAML_CANDIDATES`)在注册后
128
+ 必须包含本宿主——**这是 core 的机制**,但只有本仓能把它端到端证明。"""
129
+
130
+ def test_it_is_in_valid_formats(self):
131
+ from pactkit.generators.deploy_base import _ensure_entry_point_deployers
132
+ from pactkit.profiles import VALID_FORMATS
133
+
134
+ _ensure_entry_point_deployers()
135
+ assert "opencode" in VALID_FORMATS
136
+
137
+ def test_its_config_path_is_a_discovery_candidate(self):
138
+ from pactkit.generators.deploy_base import _ensure_entry_point_deployers
139
+ from pactkit.profiles import PACTKIT_YAML_CANDIDATES
140
+
141
+ _ensure_entry_point_deployers()
142
+ assert SPEC.pactkit_yaml_path in PACTKIT_YAML_CANDIDATES
143
+
144
+
145
+ class TestTheContractHasTeeth:
146
+ """反证:改坏声明,上面必须有测试变红。
147
+
148
+ 没有这一条,「搬过来了」只是两个仓各自为绿的巧合。
149
+ """
150
+
151
+ def test_the_profile_is_what_get_profile_answers(self):
152
+ """把本仓的声明换掉,`get_profile` 必须跟着变。
153
+
154
+ 若它不变,说明 core 还留着一份——那正是「复制一份、core 还留一份」的形态。
155
+ """
156
+ import dataclasses
157
+
158
+ import pactkit_opencode.declarations as decl
159
+ from pactkit.generators.deploy_base import _ensure_entry_point_deployers
160
+ from pactkit.profiles import get_registration, register_adapter
161
+
162
+ _ensure_entry_point_deployers()
163
+ broken = dataclasses.replace(SPEC, display_name="Not OpenCode")
164
+ register_adapter(
165
+ "opencode",
166
+ deployer=get_registration("opencode").deployer,
167
+ profile=broken,
168
+ )
169
+ try:
170
+ assert get_profile("opencode").display_name == "Not OpenCode", (
171
+ "改掉本仓的声明后 get_profile 没变——core 侧还有第二份真相源"
172
+ )
173
+ finally:
174
+ register_adapter(
175
+ "opencode",
176
+ deployer=get_registration("opencode").deployer,
177
+ profile=decl.PROFILE,
178
+ )
179
+
180
+
181
+ class TestTheCLIPolicyIsDeclaredHere:
182
+ """`cli_policy` 是**本宿主的**声明:它决定部署产物要不要依赖 PactKit CLI。
183
+
184
+ core 侧对应断言(`test_story_slim084.py::TestHasPactkitCli`)已随之删除。
185
+ """
186
+
187
+ def test_the_policy(self):
188
+ assert SPEC.cli_policy is CLIPolicy.REQUIRED
189
+
190
+ def test_has_pactkit_cli_is_derived(self):
191
+ assert SPEC.has_pactkit_cli is True
@@ -0,0 +1,399 @@
1
+ """SPEC-4.0-22 Step 7 — 本宿主的线格式契约。
2
+
3
+ **为什么这份测试在本仓而不是 core。** 线格式(`INPUT_TRANSLATION`)自 Step 7
4
+ 起是本 adapter 的声明,不是 core 的数据。core 保留的是**引擎**:识别 → 阶梯
5
+ (`_skip` / `_uncovered` / 翻译)→ fail-closed 判定。事实在谁那里,断言就在谁
6
+ 那里。
7
+
8
+ core 侧留了一个 skip 的占位(`test_story_slim20260918dda35a04d01d_opencode_pretool.py`
9
+ 的 opencode-specific 用例),理由是「线格式契约由 pactkit-opencode 自己的套件
10
+ 覆盖」——**那份承诺就是本文件**。没有它,core 的 skip 就只是把覆盖删掉。
11
+
12
+ **为什么不合成一份映射来测。** 合成映射只能证明 core 的引擎能处理「测试作者编
13
+ 出来的表」,证明不了**本仓的声明没有漂移**。2026-09-19 的 P0 正是漂移:`edit` 的
14
+ `replaceAll` 漏映射,门禁按单次替换模拟,于是 OpenCode 放行、原生拦下。所以下面
15
+ 每一条都钉 `declarations.INPUT_TRANSLATION` 本身。
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import dataclasses
21
+ from typing import cast
22
+
23
+ import pytest
24
+ from pactkit.host_payloads import translate_host_payload
25
+ from pactkit.profiles import InputTranslation
26
+
27
+ from pactkit_opencode.declarations import INPUT_TRANSLATION
28
+
29
+ SPEC = cast(InputTranslation, INPUT_TRANSLATION)
30
+
31
+ #: 门禁真的会读的 canonical 键——`grep -rho 'tool_input\.get("[a-z_]*"' src/pactkit/*.py`
32
+ #: 的全部取值,去掉 `skill`(那是 Skill 工具的参数,不属任何宿主的写面)。
33
+ #: 少一个键不是「少一个字段」,是少一道判据。
34
+ GATE_READ_KEYS = (
35
+ "command", "content", "file_path", "new_string", "old_string", "replace_all",
36
+ )
37
+
38
+
39
+ def _translate(tool: str, args: dict) -> dict:
40
+ """走生产路径:宿主线格式 → 规范形。"""
41
+ return translate_host_payload(
42
+ {"tool": tool, "args": args, "sessionID": "s", "callID": "c", "cwd": "/tmp"}
43
+ )
44
+
45
+
46
+ class TestTheDeclarationIsReal:
47
+ def test_it_is_not_none(self):
48
+ """Step 7 之前这里可以是 `None`——那比 bug 更糟,通道会整条静默失效。"""
49
+ assert SPEC is not None, (
50
+ "opencode 声明了 `INPUT_TRANSLATION = None`——core 侧那份已经删除,"
51
+ "于是没有任何东西认得 OpenCode 负载,整条通道 fail-open"
52
+ )
53
+
54
+ def test_it_registers_and_is_readable_through_the_registry(self):
55
+ """声明必须真的到达 registry——生产读路径只从那里读。"""
56
+ from pactkit.generators.deploy_base import _ensure_entry_point_deployers
57
+ from pactkit.profiles import input_translations
58
+
59
+ _ensure_entry_point_deployers()
60
+ registered = input_translations().get("opencode")
61
+ assert registered is not None, "discovery 之后 registry 里没有 opencode"
62
+ assert registered is SPEC, "registry 里那份不是本仓声明的这个对象"
63
+
64
+
65
+ class TestTheRecognitionSignal:
66
+ def test_it_recognises_a_host_payload(self):
67
+ """有小写 `tool` + `args`、没有规范形 `tool_name` = OpenCode。"""
68
+ assert SPEC.recognizes(
69
+ {"tool": "write", "args": {"filePath": "a.py", "content": "x"}}
70
+ )
71
+
72
+ def test_it_does_not_claim_a_canonical_payload(self):
73
+ """规范形必须原样放行——翻译对既有通道零影响。"""
74
+ payload = {"tool_name": "Write", "tool_input": {"file_path": "a.py"}}
75
+ assert not SPEC.recognizes(payload)
76
+ assert translate_host_payload(payload) is payload
77
+
78
+
79
+ class TestTheMappingsAreComplete:
80
+ def test_replace_all_is_mapped(self):
81
+ """2026-09-19 P0 的那个字段。它漏掉时门禁按单次替换模拟,于是放行。"""
82
+ assert "replaceAll" in SPEC.arg_map["edit"], (
83
+ "edit 的 arg_map 少了 `replaceAll` → 门禁会把全量替换当成单次替换,"
84
+ "第一次匹配落在注释里时看不到弱化,**OpenCode 放行而原生拦下**"
85
+ )
86
+
87
+ def test_every_gate_read_key_is_reachable(self):
88
+ """双向闭合的一半:门禁读的每个键,映射表里都要有来源。"""
89
+ mapped = {c for m in SPEC.arg_map.values() for c in m.values()}
90
+ missing = [k for k in GATE_READ_KEYS if k not in mapped]
91
+ assert not missing, (
92
+ f"门禁读 {missing},本仓的映射表却没有对应项——"
93
+ "这正是 replaceAll 漏掉时的形状"
94
+ )
95
+
96
+ def test_the_mapping_invents_no_keys(self):
97
+ """双向闭合的另一半:映射产出的键必须都是门禁真的会读的。
98
+
99
+ 多出来的键不是「多一个字段」,是一个**没有消费者**的声明——
100
+ 它让表看起来更完整,实际是一条死通道。
101
+ """
102
+ mapped = {c for m in SPEC.arg_map.values() for c in m.values()}
103
+ extra = sorted(mapped - set(GATE_READ_KEYS))
104
+ assert not extra, (
105
+ f"映射产出了门禁不读的键 {extra}——死通道,删掉或补上消费者"
106
+ )
107
+
108
+ def test_every_write_capable_tool_is_in_the_tool_map(self):
109
+ """可写工具必须都在表里;不在的靠 `uncovered_write_tools` 拒绝。"""
110
+ assert set(SPEC.tool_map) | set(SPEC.uncovered_write_tools) >= {
111
+ "write", "edit", "patch",
112
+ }
113
+
114
+
115
+ class TestTheTranslationLadder:
116
+ def test_a_mapped_edit_becomes_a_canonical_edit(self):
117
+ out = _translate(
118
+ "edit",
119
+ {"filePath": "a.py", "oldString": "x", "newString": "y"},
120
+ )
121
+ assert out["tool_name"] == "Edit"
122
+ assert out["tool_input"] == {
123
+ "file_path": "a.py", "old_string": "x", "new_string": "y",
124
+ }
125
+
126
+ def test_replace_all_survives_the_translation(self):
127
+ out = _translate(
128
+ "edit",
129
+ {"filePath": "a.py", "oldString": "x", "newString": "y",
130
+ "replaceAll": True},
131
+ )
132
+ assert out["tool_input"]["replace_all"] is True
133
+
134
+ def test_a_mapped_write_becomes_a_canonical_write(self):
135
+ out = _translate("write", {"filePath": "a.py", "content": "x"})
136
+ assert out["tool_name"] == "Write"
137
+ assert out["tool_input"] == {"file_path": "a.py", "content": "x"}
138
+
139
+ def test_an_uncovered_write_tool_is_refused_not_guessed(self):
140
+ """`patch` 的参数形态未取证——拒绝而非放行,且**不是**半判。"""
141
+ out = _translate("patch", {"filePath": "a.py", "patchText": "x"})
142
+ assert out.get("_uncovered") == "patch", (
143
+ "未取证的写工具必须走 `_uncovered`,不能被猜成 Write"
144
+ )
145
+
146
+ def test_an_unknown_tool_carrying_a_path_is_refused(self):
147
+ """未知 + 带路径 = 拒绝。兜底成 Write 会丢掉 `content`,正是 P0 形状。"""
148
+ out = _translate("teleport", {"filePath": "a.py", "content": "x"})
149
+ assert out.get("_uncovered") == "teleport"
150
+
151
+ @pytest.mark.parametrize("tool", ["read", "glob", "grep", "list", "webfetch"])
152
+ def test_read_only_tools_are_not_gated(self, tool):
153
+ """误报会让整条通道不可用——只读工具不进门禁判定。"""
154
+ out = _translate(tool, {"filePath": "a.py"})
155
+ assert out.get("_skip") is True
156
+
157
+ def test_an_unknown_tool_without_a_path_is_skipped(self):
158
+ """没有写入面 = 不进门禁判定,否则每个新只读工具都会被拦。"""
159
+ out = _translate("brandnew", {"foo": "bar"})
160
+ assert out.get("_skip") is True
161
+
162
+
163
+ class TestTheContractHasTeeth:
164
+ """反证:删掉关键映射,上面必须有测试变红。
165
+
166
+ 没有这一条,「覆盖搬过去了」只是两个仓各自为绿的巧合——真正的判据是
167
+ **本仓的测试会因为这个声明变坏而变红**。
168
+ """
169
+
170
+ def _with_replace_all_removed(self):
171
+ broken = dataclasses.replace(
172
+ SPEC,
173
+ arg_map={**SPEC.arg_map, "edit": {
174
+ k: v for k, v in SPEC.arg_map["edit"].items()
175
+ if k != "replaceAll"
176
+ }},
177
+ )
178
+ return broken
179
+
180
+ def test_removing_replace_all_breaks_the_completeness_check(self):
181
+ broken = self._with_replace_all_removed()
182
+ mapped = {c for m in broken.arg_map.values() for c in m.values()}
183
+ assert "replace_all" not in mapped, (
184
+ "变异没有生效——下面那条断言会因错误的原因通过"
185
+ )
186
+
187
+ def test_removing_replace_all_breaks_the_translation(self, monkeypatch):
188
+ """走真实翻译路径,证明变异**改变了行为**而不只是改了数据。"""
189
+ import pactkit.host_payloads as hp
190
+
191
+ monkeypatch.setattr(
192
+ hp, "input_translations", lambda: {"opencode": self._with_replace_all_removed()}
193
+ )
194
+ out = hp.translate_host_payload({
195
+ "tool": "edit",
196
+ "args": {"filePath": "a.py", "oldString": "x", "newString": "y",
197
+ "replaceAll": True},
198
+ "cwd": "/tmp",
199
+ })
200
+ assert "replace_all" not in out["tool_input"], (
201
+ "删掉 replaceAll 之后翻译仍然产出 replace_all——"
202
+ "说明它来自别处,本仓的声明不是唯一真相源"
203
+ )
204
+
205
+
206
+ # ---------------------------------------------------------------------------
207
+ # SPEC-4.0-22 Step 7 — 跨通道一致性(core 的 `_gated` 扫不到的那一格)
208
+ # ---------------------------------------------------------------------------
209
+
210
+
211
+ class TestThisChannelAgreesWithTheCanonicalVerdict:
212
+ """core 的 `_gated` 让每条绕过用例自动扫过所有通道,但 **opencode 那一格
213
+ 需要本 adapter 的声明**,core-only 环境渲不出来(core 侧记入 `_UNCOVERED`
214
+ 并由一条 coverage accounting 用例断言它正好等于缺失集)。
215
+
216
+ 所以那一格补在这里:**真实 opencode 渲染 → core 的 hook 判定 == 规范形判定**。
217
+ core 管 canonical semantics 与「可得通道的一致性框架」;本仓管
218
+ 「真实 opencode 线格式与 canonical verdict 一致」。
219
+ """
220
+
221
+ #: 已知绕过形态(与 core 的 astra 用例同源)。每条都要在**本通道**上被拦。
222
+ BYPASSES = (
223
+ {
224
+ "name": "write-protected-path",
225
+ "canonical": {
226
+ "tool_name": "Write",
227
+ "tool_input": {"file_path": ".synthetic/hooks.json", "content": "x"},
228
+ },
229
+ "opencode": {"tool": "write", "args": {"filePath": ".synthetic/hooks.json", "content": "x"}},
230
+ },
231
+ {
232
+ "name": "edit-replace-all-first-match-in-comment",
233
+ "canonical": {
234
+ "tool_name": "Edit",
235
+ "tool_input": {
236
+ "file_path": ".synthetic/pactkit.yaml",
237
+ "old_string": "true", "new_string": "false", "replace_all": True,
238
+ },
239
+ },
240
+ "opencode": {
241
+ "tool": "edit",
242
+ "args": {
243
+ "filePath": ".synthetic/pactkit.yaml",
244
+ "oldString": "true", "newString": "false", "replaceAll": True,
245
+ },
246
+ },
247
+ },
248
+ {
249
+ "name": "write-weakened-config",
250
+ "canonical": {
251
+ "tool_name": "Write",
252
+ "tool_input": {
253
+ "file_path": ".claude/pactkit.yaml",
254
+ "content": "enforcement:\n tamper_guard: false\n",
255
+ },
256
+ },
257
+ "opencode": {
258
+ "tool": "write",
259
+ "args": {
260
+ "filePath": ".claude/pactkit.yaml",
261
+ "content": "enforcement:\n tamper_guard: false\n",
262
+ },
263
+ },
264
+ },
265
+ )
266
+
267
+ @pytest.fixture
268
+ def repo(self, tmp_path, monkeypatch):
269
+ # 门禁要开着才有判定可比——core 侧同源用例也是这么夹的。
270
+ from pactkit import commit_gate
271
+
272
+ (tmp_path / ".git" / "hooks").mkdir(parents=True)
273
+ (tmp_path / ".pactkit" / "enforcement").mkdir(parents=True)
274
+ (tmp_path / ".claude").mkdir(parents=True)
275
+ monkeypatch.setattr(
276
+ commit_gate,
277
+ "_enforcement_settings",
278
+ lambda root: {
279
+ "protected_branches": ["main", "master"],
280
+ "allow_direct_push": False,
281
+ "tamper_guard": True,
282
+ },
283
+ )
284
+ # 弱化判定要有一个**旧值**可比:`replace_all` 那条把 `true` 改成
285
+ # `false`,没有这份配置就没有可弱化的东西(core 侧同源用例也先写它)。
286
+ (tmp_path / ".synthetic").mkdir(parents=True, exist_ok=True)
287
+ (tmp_path / ".synthetic" / "pactkit.yaml").write_text(
288
+ "# true\nenforcement:\n tamper_guard: true\n", encoding="utf-8"
289
+ )
290
+ return tmp_path
291
+
292
+ @pytest.fixture
293
+ def protected_host(self):
294
+ """一个声明了 `protected` 的宿主——`classic` 的 protected 是空的,
295
+ 所以 `.synthetic/hooks.json` 这类受保护路径需要它才存在。
296
+
297
+ **只能依赖已发布的 API。** 早先这里靠 `sys.path` 指向 core 仓的
298
+ `tests/unit/` 去 import `_synthetic_host`——那是**兄弟 checkout**,
299
+ 既不随包发布,也不在 CI 的 checkout 里:本机绿、CI 直接
300
+ `ModuleNotFoundError`(2026-09-26 实测)。core 的测试 helper 不是
301
+ core 的公共 API,adapter 仓不能伸手去够。
302
+
303
+ 就地用 `register_adapter` 建一个,profile 从 core 自带的 builtin 派生
304
+ (那条路径本来就注册着,不依赖任何兄弟仓),用完按快照整体还原。
305
+ """
306
+ import dataclasses
307
+
308
+ from pactkit.generators.deploy_base import _ensure_entry_point_deployers
309
+ from pactkit.profiles import (
310
+ FORMAT_PROFILES,
311
+ SecurityDeclaration,
312
+ SecurityFacts,
313
+ _ADAPTER_REGISTRY,
314
+ register_adapter,
315
+ )
316
+
317
+ class _Deployer:
318
+ def deploy(self, config=None, target=None):
319
+ return None
320
+
321
+ # Discovery first, then snapshot — a snapshot taken before it would
322
+ # make the restore wipe every adapter that registered afterwards.
323
+ _ensure_entry_point_deployers()
324
+ snapshot = dict(_ADAPTER_REGISTRY)
325
+ register_adapter(
326
+ "protected-host",
327
+ deployer=_Deployer,
328
+ profile=dataclasses.replace(
329
+ FORMAT_PROFILES["classic"],
330
+ name="protected-host",
331
+ project_config_dir=".synthetic",
332
+ pactkit_yaml_path=".synthetic/pactkit.yaml",
333
+ ),
334
+ security=SecurityDeclaration(
335
+ env_capable=SecurityFacts.empty(),
336
+ protected=SecurityFacts.declared({".synthetic/hooks.json"}),
337
+ conditional=SecurityFacts.empty(),
338
+ ),
339
+ source="test",
340
+ )
341
+ try:
342
+ yield
343
+ finally:
344
+ _ADAPTER_REGISTRY.clear()
345
+ _ADAPTER_REGISTRY.update(snapshot)
346
+
347
+ @pytest.mark.parametrize("case", BYPASSES, ids=lambda c: c["name"])
348
+ def test_the_verdict_matches_canonical(self, repo, protected_host, case):
349
+ import json
350
+
351
+ from pactkit.commit_gate import hook_entry
352
+
353
+ # **绝对路径**:`tamper_guard._relpath` 对相对路径 resolve 到进程 CWD 而非
354
+ # 项目根 → 返回 None → 读作「不是我们的文件」而放行。用相对路径测出来的
355
+ # 「一致」是假的(core 侧同源用例已记录这个坑)。
356
+ def _abs(args, key):
357
+ return {k: (str(repo / v) if k == key else v) for k, v in args.items()}
358
+
359
+ canonical_payload = {
360
+ **case["canonical"],
361
+ "tool_input": _abs(case["canonical"]["tool_input"], "file_path"),
362
+ "cwd": str(repo),
363
+ }
364
+ opencode_payload = {
365
+ **case["opencode"],
366
+ "args": _abs(case["opencode"]["args"], "filePath"),
367
+ "cwd": str(repo),
368
+ }
369
+
370
+ canonical = hook_entry(json.dumps(canonical_payload), repo)
371
+ via_opencode = hook_entry(
372
+ json.dumps(translate_host_payload(opencode_payload)), repo
373
+ )
374
+ assert via_opencode == canonical, (
375
+ f"opencode 通道与规范形判定不一致——翻译丢了字段\n"
376
+ f" 规范形: {canonical}\n opencode: {via_opencode}"
377
+ )
378
+ assert via_opencode[1] == 2, (
379
+ f"这条绕过在 opencode 通道上**没被拦**:{via_opencode[0][:160]}"
380
+ )
381
+
382
+ def test_the_sweep_is_not_vacuous(self, repo, protected_host):
383
+ """反证:换成一条**不该拦**的负载,上面那个等式仍然成立。
384
+
385
+ 等式单独存在时是没有鉴别力的——两条通道都放行也满足 `==`。
386
+ """
387
+ import json
388
+
389
+ from pactkit.commit_gate import hook_entry
390
+
391
+ payload = {"tool_name": "Bash", "tool_input": {"command": "ls"}, "cwd": str(repo)}
392
+ assert hook_entry(json.dumps(payload), repo)[1] == 0
393
+ via_opencode = hook_entry(
394
+ json.dumps(translate_host_payload(
395
+ {"tool": "bash", "args": {"command": "ls"}, "cwd": str(repo)}
396
+ )),
397
+ repo,
398
+ )
399
+ assert via_opencode[1] == 0
@@ -1 +0,0 @@
1
- pactkit<3.1.0,>=3.0.1
@@ -1,126 +0,0 @@
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
- Regenerate with: tests/scripts/refresh_host_body_golden.py
16
- """
17
-
18
- from __future__ import annotations
19
-
20
- import hashlib
21
- import json
22
- import subprocess
23
- import sys
24
- from pathlib import Path
25
-
26
- import pytest
27
-
28
- HOST = "opencode"
29
- GOLDEN = Path(__file__).parent / "fixtures" / "host_body_golden.json"
30
- DEPLOY_SUFFIXES = (".md", ".json", ".toml")
31
-
32
-
33
- def _core_version() -> str:
34
- import importlib.metadata as m
35
-
36
- return m.version("pactkit")
37
-
38
-
39
- def _normalize(content: str, deploy_root: str) -> str:
40
- """Strip only the deterministic environment difference: the deploy root
41
- path baked into rendered prompts. Rule text stays — normalizing it would
42
- swallow the drift this suite exists to catch."""
43
- return content.replace(deploy_root, "<DEPLOY_ROOT>")
44
-
45
-
46
- def _deployed_digests(tmp_path: Path) -> tuple[dict[str, str] | None, subprocess.CompletedProcess]:
47
- home = tmp_path / "home"
48
- target = tmp_path / "deploy"
49
- home.mkdir()
50
- proc = subprocess.run(
51
- [sys.executable, "-m", "pactkit", "init", "--format", HOST, "--target", str(target)],
52
- capture_output=True, text=True, timeout=180,
53
- env={"PATH": "/usr/bin:/bin", "HOME": str(home), "COLUMNS": "200"},
54
- )
55
- if proc.returncode != 0:
56
- return None, proc
57
- digests: dict[str, str] = {}
58
- for path in sorted(target.rglob("*")):
59
- if not path.is_file() or path.suffix not in DEPLOY_SUFFIXES:
60
- continue
61
- body = _normalize(path.read_text(encoding="utf-8"), str(target))
62
- digests[path.relative_to(target).as_posix()] = hashlib.sha256(
63
- body.encode("utf-8")
64
- ).hexdigest()
65
- return digests, proc
66
-
67
-
68
- def _assert_no_drift(golden_digests: dict[str, str], live: dict[str, str]) -> None:
69
- """The one comparison both the golden test and the tamper probe use."""
70
- changed = sorted(
71
- k for k in set(golden_digests) & set(live) if golden_digests[k] != live[k]
72
- )
73
- removed = sorted(set(golden_digests) - set(live))
74
- added = sorted(set(live) - set(golden_digests))
75
- assert not (changed or removed or added), (
76
- f"prompt body drift: changed={changed} removed={removed} added={added} "
77
- f"— if the core prompts changed, regenerate the golden "
78
- f"(tests/scripts/refresh_host_body_golden.py) or publish the matching "
79
- f"core release first (PENDING-COMPATIBLE-CORE)"
80
- )
81
-
82
-
83
- class TestHostBodySnapshot:
84
- def test_bodies_match_the_golden(self, tmp_path):
85
- golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
86
- core_v = _core_version()
87
- if golden["core_version"] != core_v:
88
- pytest.fail(
89
- f"PENDING-COMPATIBLE-CORE: the {HOST} golden was generated "
90
- f"against core {golden['core_version']}, this environment has "
91
- f"{core_v}. Either regenerate it after the core change "
92
- f"(tests/scripts/refresh_host_body_golden.py) or publish the "
93
- f"matching core release first (spec R8 release-window semantics)."
94
- )
95
- live, proc = _deployed_digests(tmp_path)
96
- assert live is not None, (
97
- f"{HOST} deploy failed: {proc.stdout[-300:]}{proc.stderr[-300:]}"
98
- )
99
- _assert_no_drift(golden["body_digests"], live)
100
-
101
- def test_a_body_edit_is_caught_by_the_same_comparison(self, tmp_path):
102
- """Non-vacuity, end to end: deploy, edit one prompt body in place,
103
- and the SAME comparison that guards the golden must flag it."""
104
- golden = json.loads(GOLDEN.read_text(encoding="utf-8"))
105
- live, proc = _deployed_digests(tmp_path)
106
- assert live is not None, f"{HOST} deploy failed: {proc.stderr[-300:]}"
107
- target = tmp_path / "deploy"
108
- # Pick a real deployed prompt body and delete one rule line from it.
109
- victims = sorted(
110
- p for p in target.rglob("*.md") if p.relative_to(target).as_posix() in golden["body_digests"]
111
- )
112
- assert victims, "no deployed .md body found to tamper with"
113
- victim = victims[0]
114
- rel = victim.relative_to(target).as_posix()
115
- body = victim.read_text(encoding="utf-8")
116
- victim.write_text(body + "\n- deleted in a review\n", encoding="utf-8")
117
-
118
- tampered = {
119
- p.relative_to(target).as_posix(): hashlib.sha256(
120
- _normalize(p.read_text(encoding="utf-8"), str(target)).encode("utf-8")
121
- ).hexdigest()
122
- for p in sorted(target.rglob("*"))
123
- if p.is_file() and p.suffix in DEPLOY_SUFFIXES
124
- }
125
- with pytest.raises(AssertionError, match=rel):
126
- _assert_no_drift(golden["body_digests"], tampered)