pactkit-opencode 3.0.3__tar.gz → 3.0.5__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 (28) hide show
  1. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/PKG-INFO +1 -1
  2. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/pyproject.toml +1 -1
  3. pactkit_opencode-3.0.5/src/pactkit_opencode/__init__.py +20 -0
  4. pactkit_opencode-3.0.5/src/pactkit_opencode/declarations.py +209 -0
  5. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/src/pactkit_opencode/deployer.py +11 -2
  6. pactkit_opencode-3.0.5/src/pactkit_opencode/enforcement.py +59 -0
  7. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/src/pactkit_opencode.egg-info/PKG-INFO +1 -1
  8. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/src/pactkit_opencode.egg-info/SOURCES.txt +4 -0
  9. pactkit_opencode-3.0.5/tests/test_opencode_enforcement_channel.py +115 -0
  10. pactkit_opencode-3.0.5/tests/test_step6_self_sufficiency.py +128 -0
  11. pactkit_opencode-3.0.3/src/pactkit_opencode/__init__.py +0 -8
  12. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/README.md +0 -0
  13. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/setup.cfg +0 -0
  14. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/src/pactkit_opencode.egg-info/dependency_links.txt +0 -0
  15. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/src/pactkit_opencode.egg-info/entry_points.txt +0 -0
  16. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/src/pactkit_opencode.egg-info/requires.txt +0 -0
  17. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/src/pactkit_opencode.egg-info/top_level.txt +0 -0
  18. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/tests/test_a1_config_recovery.py +0 -0
  19. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/tests/test_a2_mcp_retired.py +0 -0
  20. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/tests/test_bug035_opencode_dual_layer.py +0 -0
  21. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/tests/test_deploy_opencode_parity.py +0 -0
  22. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/tests/test_gate_plugin_deploy.py +0 -0
  23. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/tests/test_host_body_snapshot.py +0 -0
  24. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/tests/test_story069_opencode_format.py +0 -0
  25. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/tests/test_story070_opencode_compliance.py +0 -0
  26. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/tests/test_story071_opencode_config_parity.py +0 -0
  27. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/tests/test_story073_command_model_routing.py +0 -0
  28. {pactkit_opencode-3.0.3 → pactkit_opencode-3.0.5}/tests/test_story_work_units.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pactkit-opencode
3
- Version: 3.0.3
3
+ Version: 3.0.5
4
4
  Summary: PactKit adapter for OpenCode IDE
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pactkit-opencode"
7
- version = "3.0.3"
7
+ version = "3.0.5"
8
8
  description = "PactKit adapter for OpenCode IDE"
9
9
  requires-python = ">=3.10"
10
10
  license = "MIT"
@@ -0,0 +1,20 @@
1
+ """PactKit adapter for OpenCode IDE.
2
+
3
+ Auto-registers OpenCodeDeployer via entry_points when imported.
4
+ """
5
+
6
+ from pactkit_opencode.deployer import OpenCodeDeployer
7
+
8
+ # The second import is for its side effect: it registers OpenCode's
9
+ # enforcement channel (SPEC-4.0-06 R3), and registration IS Core's
10
+ # availability signal — an adapter that is not installed cannot register, so
11
+ # `format == "all"` never installs a channel for a host that never deployed.
12
+ #
13
+ # This adapter's channel is the one exception to that sentence: Core installs
14
+ # nothing for opencode, because `_deploy_gate_plugin()` installs the plugin
15
+ # itself, globally. Registering still matters — without it Core answers "no
16
+ # channel" for a host that has one, and `gate_coverage` reports the pre-tool
17
+ # gates as unenforced while the plugin is on disk and blocking.
18
+ from pactkit_opencode.enforcement import opencode_channel # noqa: F401
19
+
20
+ __all__ = ["OpenCodeDeployer"]
@@ -0,0 +1,209 @@
1
+ """Opencode's own declarations (SPEC-4.0-22 Step 6).
2
+
3
+ These lived in Core's transitional seed (`_CORE_CAPABILITIES`, `_CORE_SECURITY`,
4
+ `_CORE_PRETOOL_SURFACES`, `_CORE_DEPLOYMENT_CLAIMS`) until Step 6. While they
5
+ did, this adapter could not answer for itself: `import pactkit_opencode` read Core's
6
+ table
7
+ for its own identity, so removing the seed broke the import rather than the
8
+ deployment.
9
+
10
+ Core still owns the **vocabulary** (`capability.CAPABILITY_VOCABULARY`), the
11
+ four levels, and the hole-filling rule in `resolve_capabilities`. What a given
12
+ Host scores on them is the Host's own declaration — that is the split
13
+ SPEC-4.0-22 R1 draws, and this module is the Opencode side of it.
14
+
15
+ Every value here was materialised from the seed Core was carrying, so this is a
16
+ move, not a re-measurement. The `Capability.evidence` strings are preserved
17
+ verbatim for the same reason: they are what the seed said, and re-wording them
18
+ here would quietly change what the registry reports.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ from pactkit.capability import Capability, CapabilityLevel
24
+ from pactkit.profiles import (
25
+ CLIPolicy,
26
+ DeploymentClaim,
27
+ FormatProfile,
28
+ InputTranslation,
29
+ SecurityDeclaration,
30
+ SecurityFacts,
31
+ )
32
+
33
+ __all__ = [
34
+ "CAPABILITIES",
35
+ "DEPLOYMENT_CLAIM",
36
+ "INPUT_TRANSLATION",
37
+ "PRETOOL_SURFACE",
38
+ "PROFILE",
39
+ "SECURITY",
40
+ ]
41
+
42
+
43
+ def _cap(
44
+ name: str, level: CapabilityLevel, implemented: bool, evidence: str
45
+ ) -> Capability:
46
+ return Capability(
47
+ name=name, level=level, implemented=implemented, evidence=evidence
48
+ )
49
+
50
+
51
+ #: The Host's profile. Core no longer owns it; this module is the source.
52
+ PROFILE = FormatProfile(
53
+ name='opencode',
54
+ display_name='OpenCode',
55
+ global_config_dir='~/.config/opencode',
56
+ project_config_dir='.opencode',
57
+ skills_dir='~/.config/opencode/skills',
58
+ agents_dir='~/.config/opencode/agents',
59
+ commands_dir='~/.config/opencode/commands',
60
+ rules_dir='~/.config/opencode/rules',
61
+ project_instructions_file='AGENTS.md',
62
+ global_instructions_file='AGENTS.md',
63
+ pactkit_yaml_path='.opencode/pactkit.yaml',
64
+ agent_format='md',
65
+ rules_import_style='inline',
66
+ excluded_agent_fields=frozenset({'memory', 'permissionMode', 'skills'}),
67
+ excluded_commands=frozenset(),
68
+ supports_model_routing=True,
69
+ skills_path_var='~/.config/opencode/skills',
70
+ command_deploy_form='flat',
71
+ agent_dialect='opencode',
72
+ command_dialect='opencode',
73
+ core_bundled=False,
74
+ cli_policy=CLIPolicy.REQUIRED,
75
+ )
76
+
77
+ #: What this Host scores on the capability vocabulary.
78
+ CAPABILITIES: dict[str, Capability] = {
79
+ 'completion_hook': _cap('completion_hook', CapabilityLevel.DEGRADED, False,
80
+ "unverified — not measured on this Host; absence of the capability is NOT "
81
+ "established (SPEC-4.0-04 R2: unverified is not unavailable)",
82
+ ),
83
+ 'git_hook': _cap('git_hook', CapabilityLevel.NATIVE, True,
84
+ 'shipped (commit_gate pre-commit chain)',
85
+ ),
86
+ 'global_instruction': _cap('global_instruction', CapabilityLevel.DEGRADED, False,
87
+ "unverified — not measured on this Host; absence of the capability is NOT "
88
+ "established (SPEC-4.0-04 R2: unverified is not unavailable)",
89
+ ),
90
+ 'native_command': _cap('native_command', CapabilityLevel.NATIVE, True,
91
+ 'shipped (generators.deployer command deploy)',
92
+ ),
93
+ 'native_skill': _cap('native_skill', CapabilityLevel.NATIVE, True,
94
+ 'shipped (generators.deployer._deploy_skills)',
95
+ ),
96
+ 'persistent_session': _cap('persistent_session', CapabilityLevel.DEGRADED, False,
97
+ "unverified — not measured on this Host; absence of the capability is NOT "
98
+ "established (SPEC-4.0-04 R2: unverified is not unavailable)",
99
+ ),
100
+ 'post_tool_hook': _cap('post_tool_hook', CapabilityLevel.DEGRADED, False,
101
+ "unverified — not measured on this Host; absence of the capability is NOT "
102
+ "established (SPEC-4.0-04 R2: unverified is not unavailable)",
103
+ ),
104
+ 'pre_tool_hook': _cap('pre_tool_hook', CapabilityLevel.EQUIVALENT, True,
105
+ "shipped as a plugin veto (tool.execute.before) — Equivalent, not Native: it "
106
+ "is PactKit's plugin, not a host facility. End-to-end in a real session "
107
+ "2026-09-18 (docs/guides/opencode-pretool-channel-evidence.md)",
108
+ ),
109
+ 'project_instruction': _cap('project_instruction', CapabilityLevel.EQUIVALENT, True,
110
+ 'shipped (deployed instruction file)',
111
+ ),
112
+ 'rule_import': _cap('rule_import', CapabilityLevel.DEGRADED, False,
113
+ "unverified — not measured on this Host; absence of the capability is NOT "
114
+ "established (SPEC-4.0-04 R2: unverified is not unavailable)",
115
+ ),
116
+ 'session_resume': _cap('session_resume', CapabilityLevel.DEGRADED, False,
117
+ "unverified — not measured on this Host; absence of the capability is NOT "
118
+ "established (SPEC-4.0-04 R2: unverified is not unavailable)",
119
+ ),
120
+ 'shell_execution': _cap('shell_execution', CapabilityLevel.NATIVE, True,
121
+ 'shipped (native bash tool)',
122
+ ),
123
+ 'structured_tool_result': _cap('structured_tool_result', CapabilityLevel.DEGRADED,
124
+ False,
125
+ "unverified — not measured on this Host; absence of the capability is NOT "
126
+ "established (SPEC-4.0-04 R2: unverified is not unavailable)",
127
+ ),
128
+ 'subagent': _cap('subagent', CapabilityLevel.DEGRADED, False,
129
+ "unverified — not measured on this Host; absence of the capability is NOT "
130
+ "established (SPEC-4.0-04 R2: unverified is not unavailable)",
131
+ ),
132
+ }
133
+
134
+ #: Files a gate must treat specially on this Host (SPEC-4.0-22 R8).
135
+ SECURITY = SecurityDeclaration(
136
+ env_capable=SecurityFacts.declared(['opencode.json']),
137
+ protected=SecurityFacts.empty(),
138
+ conditional=SecurityFacts.empty(),
139
+ )
140
+
141
+ #: Which mechanism this Host offers for pre-tool interception, when one
142
+ #: has been measured. `None` means unverified — not "cannot".
143
+ PRETOOL_SURFACE = 'tool.execute.before (plugin; veto by throwing)'
144
+
145
+ #: How this Host executes and where its verification comes from.
146
+ DEPLOYMENT_CLAIM = DeploymentClaim(
147
+ execution_mode='portable',
148
+ verification_source='adapter_native_command_facade',
149
+ )
150
+
151
+ #: Read-side wire-format data (SPEC-4.0-22 Step 4/7).
152
+ #:
153
+ #: **This Host's wire format, not Core's data about it.** Until Step 7 this
154
+ #: lived in Core (`host_payloads.OPENCODE_INPUT`) and was registered into the
155
+ #: registry at import time. That made Core the owner of a Host-specific
156
+ #: format, and it stayed invisible for exactly as long as Core seeded this Host
157
+ #: at import — the moment the seed went, Core's import-time registration raised
158
+ #: `UnknownAdapterError` in every process that had not run entry-point
159
+ #: discovery, and the gate it feeds failed **open** (measured 2026-09-24).
160
+ #:
161
+ #: The Step 6 commit on this branch declared the slot but left it `None`, which
162
+ #: would have been worse than the bug it replaced: with Core's copy deleted,
163
+ #: nothing would recognise an OpenCode payload at all and the whole channel
164
+ #: would fail open. The values below are Core's, moved **verbatim** — this is
165
+ #: a move, not a re-measurement, and re-deriving them would quietly change what
166
+ #: the gate reads.
167
+ #:
168
+ #: Two entries are explicitly *not* evidence, and say so: `patch` has an
169
+ #: unmeasured argument shape (it is listed so that "every write tool is in the
170
+ #: table" holds, and it is refused through `uncovered_write_tools`), and
171
+ #: unknown tools carrying a path-ish argument are treated as writes as a
172
+ #: fallback.
173
+ #:
174
+ #: `tool_map` is **not injective** — `patch` and `write` both canonicalise to
175
+ #: `Write`. The order below is therefore not semantic: Core's reverse lookup
176
+ #: breaks the tie on `uncovered_write_tools` (preferring the verified spelling)
177
+ #: rather than on iteration order, precisely so this declaration can be written
178
+ #: in whatever order reads best.
179
+ INPUT_TRANSLATION: InputTranslation | None = InputTranslation(
180
+ requires_keys=('tool', 'args'),
181
+ excludes_keys=('tool_name',),
182
+ tool_map={
183
+ 'bash': 'Bash',
184
+ 'edit': 'Edit',
185
+ 'patch': 'Write',
186
+ 'write': 'Write',
187
+ },
188
+ arg_map={
189
+ 'bash': {
190
+ 'command': 'command',
191
+ },
192
+ 'edit': {
193
+ 'filePath': 'file_path',
194
+ 'newString': 'new_string',
195
+ 'oldString': 'old_string',
196
+ 'replaceAll': 'replace_all',
197
+ },
198
+ 'patch': {
199
+ 'filePath': 'file_path',
200
+ 'patchText': 'content',
201
+ },
202
+ 'write': {
203
+ 'content': 'content',
204
+ 'filePath': 'file_path',
205
+ },
206
+ },
207
+ read_only_tools=frozenset({'glob', 'grep', 'list', 'read', 'task', 'todoread', 'todowrite', 'webfetch'}),
208
+ uncovered_write_tools=frozenset({'patch'}),
209
+ path_arg_keys=('filePath', 'file_path', 'path'))
@@ -28,7 +28,7 @@ from pactkit.generators.deployer import (
28
28
  _deploy_rules,
29
29
  _deploy_skills,
30
30
  )
31
- from pactkit.profiles import get_profile
31
+ from pactkit_opencode import declarations
32
32
  from pactkit.utils import atomic_write
33
33
 
34
34
  # All canonical commands deploy. Sprint has a sequential capability fallback.
@@ -121,7 +121,16 @@ class OpenCodeDeployer(DeployerBase):
121
121
  registers, nor recommends external services)
122
122
  """
123
123
 
124
- profile = get_profile("opencode")
124
+ # SPEC-4.0-22 Step 6: this Host answers for itself. These were Core's
125
+ # transitional seed until now; `complete_registration` harvests them off the
126
+ # class, so the adapter no longer reads a table Core owns. `input_translation`
127
+ # rides its own lifecycle — it is installed after the registration exists.
128
+ profile = declarations.PROFILE
129
+ capabilities = declarations.CAPABILITIES
130
+ security = declarations.SECURITY
131
+ pretool_surface = declarations.PRETOOL_SURFACE
132
+ deployment_claim = declarations.DEPLOYMENT_CLAIM
133
+ input_translation = declarations.INPUT_TRANSLATION
125
134
 
126
135
  def deploy(self, config=None, target=None):
127
136
  opencode_root = Path(target) if target else Path.home() / ".config" / "opencode"
@@ -0,0 +1,59 @@
1
+ """OpenCode's enforcement channel, stated by its owner (SPEC-4.0-06 R3).
2
+
3
+ OpenCode's veto surface is a plugin hook (``tool.execute.before``, throwing
4
+ denies the call), not a PreToolUse hook. Unlike Codex's — and unlike the
5
+ classic host's — **Core does not install it**: ``_deploy_gate_plugin()``
6
+ writes it, globally, from this adapter. There is no project-relative config
7
+ file for Core to merge into, and the plugin lives outside the deploy root, so
8
+ Core can neither install nor verify it.
9
+
10
+ So the provider returns None. That is the registry's word for "this host has
11
+ no channel for Core to install": ``_selected_channels`` yields nothing for
12
+ opencode and ``deploy("all")`` skips it, both correct — the plugin is this
13
+ adapter's to deploy and always was.
14
+
15
+ What registering buys is the *other* reader. ``gate_matrix.host_channel_registered``
16
+ asks "did this host register?", and until this module existed the answer was
17
+ no, so ``gate_coverage("opencode")`` reported ``spec_guard`` and
18
+ ``tamper_guard`` as not enforced — while the plugin was on disk and blocking.
19
+ Measured 2026-09-22: those two gates flipped from ``not_enforced`` to
20
+ ``enforced`` with the registration and nothing else, and no deploy behaviour
21
+ changed.
22
+
23
+ The looseness is the same one Codex already carries, accepted deliberately
24
+ (owner ruling 2026-09-22): registration is evidence that the *adapter* is
25
+ installed, not that the plugin reached disk. A project that installed this
26
+ adapter and never ran ``pactkit init --format opencode`` is reported as
27
+ covered. Making that claim verifiable needs a channel shape for
28
+ adapter-installed plugins; it is not this module's job to fake it with a
29
+ ``HookChannel`` whose ``relative_path`` would make Core write a
30
+ project-relative file for a channel that is global by design.
31
+ """
32
+
33
+ from __future__ import annotations
34
+
35
+ from pathlib import Path
36
+
37
+ from pactkit.commit_gate import HookChannel, register_enforcement_channel
38
+
39
+ #: MUST equal the string `gate_matrix.channels_for("opencode")` advertises, or
40
+ #: the coverage notice names a channel that is not the one installed — the
41
+ #: divergence SPEC-4.0-06 R4 records as `HookChannel`'s standing tension.
42
+ #: There is no `HookChannel` here to carry it (Core installs nothing), so the
43
+ #: agreement is asserted by `test_opencode_enforcement_channel.py` instead of
44
+ #: being structural.
45
+ ADVERTISED_IDENTITY = "tool.execute.before plugin (veto by throwing)"
46
+
47
+
48
+ def opencode_channel(root: Path) -> HookChannel | None:
49
+ """None: Core installs nothing for this host, and that is the whole point.
50
+
51
+ Returning a `HookChannel` would send Core's installer after a
52
+ project-relative file for a channel that is global by design. A None
53
+ provider is a registered host whose channel Core does not own — distinct
54
+ from a host that never registered at all.
55
+ """
56
+ return None
57
+
58
+
59
+ register_enforcement_channel("opencode", opencode_channel)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pactkit-opencode
3
- Version: 3.0.3
3
+ Version: 3.0.5
4
4
  Summary: PactKit adapter for OpenCode IDE
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
@@ -1,7 +1,9 @@
1
1
  README.md
2
2
  pyproject.toml
3
3
  src/pactkit_opencode/__init__.py
4
+ src/pactkit_opencode/declarations.py
4
5
  src/pactkit_opencode/deployer.py
6
+ src/pactkit_opencode/enforcement.py
5
7
  src/pactkit_opencode.egg-info/PKG-INFO
6
8
  src/pactkit_opencode.egg-info/SOURCES.txt
7
9
  src/pactkit_opencode.egg-info/dependency_links.txt
@@ -14,6 +16,8 @@ tests/test_bug035_opencode_dual_layer.py
14
16
  tests/test_deploy_opencode_parity.py
15
17
  tests/test_gate_plugin_deploy.py
16
18
  tests/test_host_body_snapshot.py
19
+ tests/test_opencode_enforcement_channel.py
20
+ tests/test_step6_self_sufficiency.py
17
21
  tests/test_story069_opencode_format.py
18
22
  tests/test_story070_opencode_compliance.py
19
23
  tests/test_story071_opencode_config_parity.py
@@ -0,0 +1,115 @@
1
+ """OpenCode's enforcement channel is registered by its owner, not by Core.
2
+
3
+ OpenCode's veto surface is a plugin hook (`tool.execute.before`, throwing
4
+ denies the call). `_deploy_gate_plugin()` installs it, globally, from this
5
+ adapter — Core does not install it and cannot verify it, so the provider
6
+ returns None and `_selected_channels` yields nothing.
7
+
8
+ That makes this the one host whose channel is registered but not installable
9
+ by Core, and the distinction is the whole point of the file:
10
+
11
+ * registering is what makes `gate_matrix.host_channel_registered` answer yes,
12
+ which is what makes `gate_coverage("opencode")` credit `spec_guard` and
13
+ `tamper_guard` — measured 2026-09-22, those two flipped from `not_enforced`
14
+ to `enforced` with this registration and nothing else;
15
+ * it must NOT make Core write anything, because the plugin is already this
16
+ adapter's to deploy and is global by design.
17
+
18
+ The looseness carried here is the same one Codex already has, accepted
19
+ deliberately (owner ruling 2026-09-22): registration is evidence that the
20
+ *adapter* is installed, not that the plugin reached disk.
21
+ """
22
+
23
+ from __future__ import annotations
24
+
25
+ from pactkit import commit_gate, gate_matrix
26
+ from pactkit_opencode.enforcement import ADVERTISED_IDENTITY, opencode_channel
27
+
28
+
29
+ def gate_coverage_for(fmt: str) -> dict:
30
+ """The default (measured) path — `has_git=False` so only the host layer moves."""
31
+ from pactkit.gate_matrix import gate_coverage
32
+
33
+ return gate_coverage(fmt, has_git=False)
34
+
35
+
36
+ class TestRegistration:
37
+ """The registry, not the deploy summary, is the availability signal."""
38
+
39
+ def test_importing_the_adapter_registers_a_channel(self):
40
+ import pactkit_opencode # noqa: F401 — the import IS the registration
41
+
42
+ assert gate_matrix.host_channel_registered("opencode"), (
43
+ "importing this adapter registered no opencode enforcement channel — "
44
+ "Core will then answer 'no channel' for a host whose plugin is on "
45
+ "disk and blocking"
46
+ )
47
+
48
+ def test_the_registration_is_what_credits_the_host_gates(self):
49
+ """The observable consequence, and the reason this module exists.
50
+
51
+ Before it, `gate_coverage("opencode")` reported `spec_guard` and
52
+ `tamper_guard` as not enforced. Both halves are asserted: the default
53
+ (measured) path credits them, and taking the registration away puts
54
+ them back — so the credit is coming from the registry, not from the
55
+ channel list alone.
56
+ """
57
+ import pactkit_opencode # noqa: F401
58
+
59
+ measured = gate_coverage_for("opencode")
60
+ assert measured["channel_basis"] == "registered"
61
+ for gate in ("spec_guard", "tamper_guard"):
62
+ assert gate in measured["enforced"], (
63
+ f"{gate} is not credited although the adapter registered — this "
64
+ "is the display defect the registration fixes"
65
+ )
66
+
67
+ saved = dict(commit_gate._ENFORCEMENT_CHANNELS)
68
+ try:
69
+ commit_gate._ENFORCEMENT_CHANNELS.pop("opencode")
70
+ gap = gate_coverage_for("opencode")
71
+ for gate in ("spec_guard", "tamper_guard"):
72
+ assert gate in gap["not_enforced"], gate
73
+ assert "not registered" in gap["not_enforced"]["tamper_guard"], (
74
+ "the reason must name the missing registration, not blame the "
75
+ "host — opencode does offer a pre-tool surface"
76
+ )
77
+ finally:
78
+ commit_gate._ENFORCEMENT_CHANNELS.clear()
79
+ commit_gate._ENFORCEMENT_CHANNELS.update(saved)
80
+
81
+ def test_core_still_installs_nothing_for_opencode(self, tmp_path):
82
+ """The negative: registering must not send Core's installer anywhere.
83
+
84
+ The plugin is global and lives outside the deploy root; a HookChannel
85
+ here would make Core write a project-relative file for it.
86
+ """
87
+ import pactkit_opencode # noqa: F401
88
+
89
+ assert commit_gate._selected_channels(tmp_path, "opencode") == [], (
90
+ "Core selected a channel to install for opencode — it owns none"
91
+ )
92
+ assert opencode_channel(tmp_path) is None
93
+
94
+ def test_all_does_not_pick_opencode_up(self, tmp_path):
95
+ """`all` traverses the registry, and opencode contributes no channel."""
96
+ import pactkit_opencode # noqa: F401
97
+
98
+ identities = [
99
+ channel.identity
100
+ for channel in commit_gate._selected_channels(tmp_path, "all")
101
+ ]
102
+ assert ADVERTISED_IDENTITY not in identities
103
+
104
+
105
+ class TestTheAdvertisedName:
106
+ def test_the_identity_is_the_advertised_one(self):
107
+ """R4's tension, asserted: `gate_matrix.channels_for` names this
108
+ channel in the coverage notice, and there is no `HookChannel` here to
109
+ carry the string — so nothing structural holds the two together."""
110
+ advertised = gate_matrix.channels_for("opencode", has_git=False)
111
+ assert advertised, "anti-vacuity: opencode advertises no channel at all"
112
+ assert ADVERTISED_IDENTITY in advertised, (
113
+ f"this module names the channel {ADVERTISED_IDENTITY!r} but the "
114
+ f"coverage notice advertises {advertised!r}"
115
+ )
@@ -0,0 +1,128 @@
1
+ """SPEC-4.0-22 Step 6: this adapter answers for itself.
2
+
3
+ The claim under test is not "registration is complete" — it already was, because
4
+ Core's transitional seed filled the same members. The claim is that **every one
5
+ of them comes from the adapter**.
6
+
7
+ The probe removes Core's declaration for this Host and re-seeds, then imports
8
+ the adapter in that state. It does so **without naming Core's tables**: the
9
+ claim does not depend on whether they are called `_CORE_*` (everything
10
+ published up to 3.0.4) or `_BUILTIN_*` (Step 7, where they hold the builtin
11
+ alone). A probe that hardcodes either name is coupled to a Core version — and
12
+ adapter CI installs Core from PyPI, so it would be testing the version it
13
+ happens to pull rather than the adapter.
14
+
15
+ The assertions name **properties**, not literals from the seed — the seed is
16
+ gone, so quoting it would assert the thing being ruled out.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import subprocess
22
+ import sys
23
+
24
+ HOST = "opencode"
25
+ MODULE = "pactkit_opencode"
26
+
27
+ #: Asserts Core declares nothing for this Host, then imports the adapter. Runs
28
+ #: in a fresh interpreter so the registry is exactly what `profiles` leaves
29
+ #: behind — no earlier import of this adapter can have registered it.
30
+ _UNSEED_TEMPLATE = '''
31
+ import sys
32
+ from pactkit import profiles as P
33
+
34
+ # Remove every trace of Core's declaration for this Host — by SHAPE, not by a
35
+ # hardcoded table name. Naming `_CORE_*` breaks on Step 7 core; naming
36
+ # `_BUILTIN_*` breaks on everything published before it. The claim under test
37
+ # does not depend on what the table is called, so the probe must not either.
38
+ removed = [n for n, v in list(vars(P).items())
39
+ if isinstance(v, dict) and HOST in v]
40
+ for n in removed:
41
+ vars(P)[n].pop(HOST, None)
42
+
43
+ # Re-seed through whatever this Core calls its seeder, so the registry is a
44
+ # consistent post-seed state rather than a half-emptied one.
45
+ for seeder in ("_seed_builtin", "_seed_from_core"):
46
+ fn = getattr(P, seeder, None)
47
+ if fn is not None:
48
+ fn()
49
+ break
50
+
51
+ # The precondition the import has to survive — and the guard against a probe
52
+ # that proves nothing. On Core 3.0.4 the removal above is what establishes it;
53
+ # on Step 7 Core it was already true, because there is no seed to remove. Both
54
+ # readings are the same claim, so this assertion is meaningful on both rather
55
+ # than demanding that something was removed (which Step 7 Core would fail for
56
+ # having done its job).
57
+ assert HOST not in P.FORMAT_PROFILES, (
58
+ "Core still carries this Host's profile when the adapter loads"
59
+ )
60
+ assert HOST not in P._ADAPTER_REGISTRY, (
61
+ "this Host is registered before its adapter has loaded"
62
+ )
63
+
64
+ import MODULE # noqa: F401 — the import IS the assertion
65
+
66
+ from pactkit.profiles import get_registration
67
+ reg = get_registration(HOST)
68
+ assert reg.complete, "registration incomplete after an unseeded import: " + reg.state
69
+ assert reg.source == P.SOURCE_ENTRY_POINT, "source is " + reg.source
70
+ print("OK")
71
+ '''
72
+
73
+
74
+ def _code(import_adapter: bool = True) -> str:
75
+ """The probe, optionally without the adapter import (the control).
76
+
77
+ The control substitution happens **before** the MODULE/HOST placeholders are
78
+ filled in. Doing it after silently matched nothing — the literal it looked
79
+ for no longer contained `MODULE` — and the control then ran the full probe
80
+ and passed for the wrong reason. Measured 2026-09-24.
81
+ """
82
+ code = _UNSEED_TEMPLATE
83
+ if not import_adapter:
84
+ before = code
85
+ code = code.replace(
86
+ "import MODULE # noqa: F401 — the import IS the assertion",
87
+ "pass # control: do NOT import the adapter",
88
+ )
89
+ assert code != before, "the control substitution matched nothing"
90
+ return code.replace("HOST", repr(HOST)).replace("MODULE", MODULE)
91
+
92
+
93
+ def _run(code: str) -> subprocess.CompletedProcess:
94
+ return subprocess.run(
95
+ [sys.executable, "-c", code], capture_output=True, text=True, timeout=180
96
+ )
97
+
98
+
99
+ def test_the_adapter_stands_up_without_its_core_seed():
100
+ """The unseeded import is the whole claim — it raised before Step 6."""
101
+ result = _run(_code())
102
+ assert result.returncode == 0, (
103
+ "the adapter could not stand up without Core's transitional seed — "
104
+ "which is exactly what Step 7 deletes:\n" + result.stderr[-2000:]
105
+ )
106
+
107
+
108
+ def test_the_probe_moves_when_the_adapter_is_not_imported():
109
+ """Control: the probe's assertions must distinguish the two states.
110
+
111
+ A control that merely re-runs the probe with one of its own assertions
112
+ disabled proves nothing — it passes because the check was removed, not
113
+ because the probe is sound. (Measured: the first version of this control did
114
+ exactly that and passed for that reason.)
115
+
116
+ The discriminating question is whether the probe's assertions *move*. So
117
+ this runs the same removal **without importing the adapter**: now nothing
118
+ has registered opencode, and the probe must report that — a registration
119
+ that is absent or incomplete, and a host still counted as transitional.
120
+ """
121
+ probe = _code(import_adapter=False)
122
+ result = _run(probe)
123
+ assert result.returncode != 0, (
124
+ "with no adapter imported, the probe still reported a complete "
125
+ "entry-point registration — so its assertions do not depend on the "
126
+ "adapter at all, and the main test is not measuring self-sufficiency"
127
+ )
128
+ assert "Traceback" in result.stderr, result.stderr[-800:]
@@ -1,8 +0,0 @@
1
- """PactKit adapter for OpenCode IDE.
2
-
3
- Auto-registers OpenCodeDeployer via entry_points when imported.
4
- """
5
-
6
- from pactkit_opencode.deployer import OpenCodeDeployer
7
-
8
- __all__ = ["OpenCodeDeployer"]