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.
Files changed (33) hide show
  1. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/PKG-INFO +2 -2
  2. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/pyproject.toml +2 -2
  3. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/src/pactkit_opencode/deployer.py +23 -0
  4. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/src/pactkit_opencode.egg-info/PKG-INFO +2 -2
  5. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/src/pactkit_opencode.egg-info/SOURCES.txt +5 -1
  6. pactkit_opencode-4.0.0/src/pactkit_opencode.egg-info/requires.txt +1 -0
  7. pactkit_opencode-4.0.0/tests/test_capability_contract.py +112 -0
  8. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_deploy_opencode_parity.py +17 -12
  9. pactkit_opencode-4.0.0/tests/test_host_body_snapshot.py +262 -0
  10. pactkit_opencode-4.0.0/tests/test_profile_contract.py +191 -0
  11. pactkit_opencode-4.0.0/tests/test_step8_declaration_search_producer.py +178 -0
  12. pactkit_opencode-4.0.0/tests/test_wire_format_contract.py +399 -0
  13. pactkit_opencode-3.0.5/src/pactkit_opencode.egg-info/requires.txt +0 -1
  14. pactkit_opencode-3.0.5/tests/test_host_body_snapshot.py +0 -126
  15. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/README.md +0 -0
  16. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/setup.cfg +0 -0
  17. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/src/pactkit_opencode/__init__.py +0 -0
  18. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/src/pactkit_opencode/declarations.py +0 -0
  19. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/src/pactkit_opencode/enforcement.py +0 -0
  20. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/src/pactkit_opencode.egg-info/dependency_links.txt +0 -0
  21. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/src/pactkit_opencode.egg-info/entry_points.txt +0 -0
  22. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/src/pactkit_opencode.egg-info/top_level.txt +0 -0
  23. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_a1_config_recovery.py +0 -0
  24. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_a2_mcp_retired.py +0 -0
  25. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_bug035_opencode_dual_layer.py +0 -0
  26. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_gate_plugin_deploy.py +0 -0
  27. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_opencode_enforcement_channel.py +0 -0
  28. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_step6_self_sufficiency.py +0 -0
  29. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_story069_opencode_format.py +0 -0
  30. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_story070_opencode_compliance.py +0 -0
  31. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_story071_opencode_config_parity.py +0 -0
  32. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.0}/tests/test_story073_command_model_routing.py +0 -0
  33. {pactkit_opencode-3.0.5 → pactkit_opencode-4.0.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: 4.0.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<4.1.0,>=4.0.0
@@ -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 = "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>=3.0.1,<3.1.0",
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)."""
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pactkit-opencode
3
- Version: 3.0.5
3
+ Version: 4.0.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<4.1.0,>=4.0.0
@@ -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
- def test_no_format_autodetects(self, tmp_path):
275
- """Without format param, uses auto-detect (existing behavior preserved)."""
276
- from pactkit.generators.deployer import _generate_config_if_missing
277
-
278
- (tmp_path / ".opencode").mkdir()
279
-
280
- with patch.object(Path, "cwd", return_value=tmp_path):
281
- _generate_config_if_missing()
282
-
283
- # Auto-detect should prefer .opencode
284
- yaml_path = tmp_path / ".opencode" / "pactkit.yaml"
285
- assert yaml_path.exists()
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}"