pactkit-opencode 3.1.0__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 (32) hide show
  1. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/PKG-INFO +2 -2
  2. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/pyproject.toml +2 -2
  3. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/src/pactkit_opencode/deployer.py +23 -0
  4. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/src/pactkit_opencode.egg-info/PKG-INFO +2 -2
  5. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/src/pactkit_opencode.egg-info/SOURCES.txt +1 -0
  6. pactkit_opencode-4.0.0/src/pactkit_opencode.egg-info/requires.txt +1 -0
  7. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/tests/test_deploy_opencode_parity.py +17 -12
  8. pactkit_opencode-4.0.0/tests/test_step8_declaration_search_producer.py +178 -0
  9. pactkit_opencode-3.1.0/src/pactkit_opencode.egg-info/requires.txt +0 -1
  10. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/README.md +0 -0
  11. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/setup.cfg +0 -0
  12. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/src/pactkit_opencode/__init__.py +0 -0
  13. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/src/pactkit_opencode/declarations.py +0 -0
  14. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/src/pactkit_opencode/enforcement.py +0 -0
  15. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/src/pactkit_opencode.egg-info/dependency_links.txt +0 -0
  16. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/src/pactkit_opencode.egg-info/entry_points.txt +0 -0
  17. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/src/pactkit_opencode.egg-info/top_level.txt +0 -0
  18. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/tests/test_a1_config_recovery.py +0 -0
  19. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/tests/test_a2_mcp_retired.py +0 -0
  20. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/tests/test_bug035_opencode_dual_layer.py +0 -0
  21. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/tests/test_capability_contract.py +0 -0
  22. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/tests/test_gate_plugin_deploy.py +0 -0
  23. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/tests/test_host_body_snapshot.py +0 -0
  24. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/tests/test_opencode_enforcement_channel.py +0 -0
  25. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/tests/test_profile_contract.py +0 -0
  26. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/tests/test_step6_self_sufficiency.py +0 -0
  27. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/tests/test_story069_opencode_format.py +0 -0
  28. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/tests/test_story070_opencode_compliance.py +0 -0
  29. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/tests/test_story071_opencode_config_parity.py +0 -0
  30. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/tests/test_story073_command_model_routing.py +0 -0
  31. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/tests/test_story_work_units.py +0 -0
  32. {pactkit_opencode-3.1.0 → pactkit_opencode-4.0.0}/tests/test_wire_format_contract.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pactkit-opencode
3
- Version: 3.1.0
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.2.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.1.0"
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.2.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.1.0
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.2.0,>=3.0.1
7
+ Requires-Dist: pactkit<4.1.0,>=4.0.0
@@ -20,6 +20,7 @@ tests/test_host_body_snapshot.py
20
20
  tests/test_opencode_enforcement_channel.py
21
21
  tests/test_profile_contract.py
22
22
  tests/test_step6_self_sufficiency.py
23
+ tests/test_step8_declaration_search_producer.py
23
24
  tests/test_story069_opencode_format.py
24
25
  tests/test_story070_opencode_compliance.py
25
26
  tests/test_story071_opencode_config_parity.py
@@ -0,0 +1 @@
1
+ pactkit<4.1.0,>=4.0.0
@@ -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,178 @@
1
+ """SPEC-4.0-22 Step 8 R-③-1: declaration, search and producer agree.
2
+
3
+ Before this, `.opencode/pactkit.yaml` was **declared** in
4
+ `profile.pactkit_yaml_path` and **searched** by every reader, but nothing
5
+ produced it. owner 2026-09-23:
6
+
7
+ > 这不是「缺一个文件」这么简单,而是 registration/profile 在宣称一个生命周期
8
+ > 事实,实际实现却永远不兑现;「声明 + 搜索 + 不生产」会让后续判断把一个不
9
+ > 存在的状态当成正常候选。
10
+
11
+ It could still appear, which was worse than not existing: `init --format classic`
12
+ in a project carrying a `.opencode/` directory wrote **classic's** config there,
13
+ because the path fell to `resolve_pactkit_yaml_dir`'s directory-existence
14
+ fallback (measured 2026-09-26). A file whose contents belong to another host is
15
+ a lifecycle fact nobody can act on.
16
+
17
+ The chosen fix is **A — really generate it** (owner 2026-09-26), so all four
18
+ hosts follow the same model: the project-root `pactkit.yaml` is the hand-written
19
+ shared policy, and each host directory gets a generated override.
20
+ """
21
+
22
+ from __future__ import annotations
23
+
24
+ import ast
25
+ from pathlib import Path
26
+
27
+ import pytest
28
+ import yaml
29
+
30
+ SRC = Path(__file__).resolve().parent.parent / "src"
31
+
32
+ #: String content a config template must contain. `stack: ` keeps its trailing
33
+ #: space on purpose — it is the constant half of `f"stack: {stack}\n"`, and prose
34
+ #: about the defect ("the template wrote `stack: unknown`") is not a string at
35
+ #: all, so it cannot reach this check.
36
+ TEMPLATE_MARKERS = ("version: 0.0.1", "stack: ")
37
+
38
+
39
+ def _string_literals(tree: ast.AST) -> list[str]:
40
+ """Every string this module can build, including f-string constant parts.
41
+
42
+ **AST, never grep.** A text scan cannot tell a template from a sentence
43
+ about one: it fires on the comment documenting the deletion and stays silent
44
+ when someone pastes the real thing back inside a string.
45
+ """
46
+ out: list[str] = []
47
+ for node in ast.walk(tree):
48
+ if isinstance(node, ast.Constant) and isinstance(node.value, str):
49
+ out.append(node.value)
50
+ elif isinstance(node, ast.JoinedStr):
51
+ out.extend(
52
+ part.value for part in node.values
53
+ if isinstance(part, ast.Constant) and isinstance(part.value, str)
54
+ )
55
+ return out
56
+
57
+
58
+ class TestNoTemplateSurvives:
59
+ """AC-②-2,对**三仓**都成立——包括这一个。
60
+
61
+ 本仓从来没有过内联模板,所以这条今天是白送;但 AC-②-2 判的是**状态**不是
62
+ 历史,而「多一份模板」正是最容易在别处复制粘贴回来的东西。
63
+ """
64
+
65
+ def test_the_shipped_package_contains_no_template_literal(self):
66
+ hits = [
67
+ f"{py.relative_to(SRC.parent).as_posix()}: {marker!r}"
68
+ for py in sorted(SRC.rglob("*.py"))
69
+ for marker in TEMPLATE_MARKERS
70
+ if any(marker in s for s in _string_literals(
71
+ ast.parse(py.read_text(encoding="utf-8"), filename=str(py))
72
+ ))
73
+ ]
74
+ assert not hits, (
75
+ f"a pactkit.yaml template literal is in the shipped package: {hits}. "
76
+ f"Declare WHERE the file lives and WHETHER it is needed; Core's "
77
+ f"canonical generator owns the content (Step 8 R-②-1/R-②-4)."
78
+ )
79
+
80
+ def test_the_enumeration_is_not_vacuous(self):
81
+ """反证:枚举器确实看得见字符串——包括 f-string 的常量半段。"""
82
+ found = _string_literals(ast.parse('a = "version: 0.0.1"\nb = f"stack: {x}\\n"\n'))
83
+ assert "version: 0.0.1" in found
84
+ assert "stack: " in found, f"f-string 的常量半段没被枚举到:{found}"
85
+
86
+
87
+ @pytest.fixture
88
+ def project(tmp_path, monkeypatch):
89
+ """Run the REAL `deploy()` path with `Path.home()` pointed at `tmp_path`.
90
+
91
+ `--target` is preview mode and deliberately skips project-level files (the
92
+ same guard covers `AGENTS.md` and codex's project files), so a test that
93
+ passes a target would be asserting about a path production never takes.
94
+ Without a target, `deploy()` writes its host tree to `Path.home()/.opencode`
95
+ — redirected here so the assertion can run without touching the real home.
96
+ """
97
+ proj = tmp_path / "proj"
98
+ proj.mkdir()
99
+ monkeypatch.chdir(proj)
100
+ monkeypatch.setattr(Path, "home", lambda: tmp_path / "home")
101
+ return proj
102
+
103
+
104
+ class TestTheThreeFactsAgree:
105
+ def test_the_declared_path_is_the_searched_path(self):
106
+ """声明 == 搜索。两者都从 profile 派生,分歧会立刻可见。"""
107
+ from pactkit.profiles import PACTKIT_YAML_CANDIDATES, get_profile
108
+
109
+ declared = get_profile("opencode").pactkit_yaml_path
110
+ assert declared in PACTKIT_YAML_CANDIDATES, (
111
+ f"profile 声明 {declared!r},候选表里却没有它:"
112
+ f"{list(PACTKIT_YAML_CANDIDATES)}"
113
+ )
114
+
115
+ def test_the_declared_path_is_where_this_host_writes(self, project):
116
+ """声明 == 生产者。这是缺的那一半——前两处一直一致,只有它在缺席。"""
117
+ from pactkit.profiles import get_profile
118
+
119
+ from pactkit_opencode import OpenCodeDeployer
120
+
121
+ OpenCodeDeployer().deploy()
122
+
123
+ declared = project / get_profile("opencode").pactkit_yaml_path
124
+ assert declared.is_file(), (
125
+ f"本宿主声明 {declared!r} 却从不生产它——"
126
+ f"found: {sorted(p.relative_to(project).as_posix() for p in project.rglob('pactkit.yaml'))}"
127
+ )
128
+
129
+ def test_it_is_cores_canonical_output(self, project):
130
+ """内容来自 core 的 canonical generator,不是本仓的第二份模板。
131
+
132
+ 逐字段断言会在「core 改了格式而本仓漂移」时照样通过;比字节不会。
133
+ """
134
+ from pactkit.cleaners import detect_stacks
135
+ from pactkit.config import generate_default_yaml
136
+ from pactkit.profiles import get_profile
137
+
138
+ from pactkit_opencode import OpenCodeDeployer
139
+
140
+ OpenCodeDeployer().deploy()
141
+
142
+ written = (project / get_profile("opencode").pactkit_yaml_path).read_text()
143
+ assert written == generate_default_yaml(stack=detect_stacks(project))
144
+
145
+ def test_the_config_is_valid_and_carries_the_format_version(self, project):
146
+ from pactkit.config import VALID_STACKS
147
+ from pactkit.profiles import get_profile
148
+
149
+ from pactkit_opencode import OpenCodeDeployer
150
+
151
+ OpenCodeDeployer().deploy()
152
+
153
+ data = yaml.safe_load(
154
+ (project / get_profile("opencode").pactkit_yaml_path).read_text()
155
+ )
156
+ assert data["schema_version"] == 1
157
+ assert data["stack"] in VALID_STACKS
158
+
159
+
160
+ class TestTheCrossWriteIsClosed:
161
+ def test_another_hosts_init_does_not_create_this_hosts_config(self, tmp_path):
162
+ """R-③-2:`.opencode/pactkit.yaml` 的内容 MUST NOT 由别的宿主决定。
163
+
164
+ 本仓是那个「目录存在就被劫持」的受害者,所以这条断言放在这里:
165
+ 只要 core 的生成路径再次丢掉显式 format,这个文件就会带着 classic 的
166
+ 内容出现,而本仓的声明仍然指向它。
167
+ """
168
+ from pactkit.generators.deployer import _generate_config_if_missing
169
+
170
+ (tmp_path / ".opencode").mkdir()
171
+
172
+ _generate_config_if_missing(format="classic", project_root=tmp_path)
173
+
174
+ assert not (tmp_path / ".opencode" / "pactkit.yaml").exists(), (
175
+ "classic 的 init 在 opencode 的目录里写了 pactkit.yaml —— "
176
+ "路径又被目录存在性劫持了(Step 8 R-③-2)"
177
+ )
178
+ assert (tmp_path / ".claude" / "pactkit.yaml").is_file()
@@ -1 +0,0 @@
1
- pactkit<3.2.0,>=3.0.1