pactkit-opencode 2.25.2__tar.gz → 2.27.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 (23) hide show
  1. {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/PKG-INFO +2 -2
  2. {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/pyproject.toml +2 -2
  3. {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/src/pactkit_opencode/deployer.py +156 -82
  4. {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/src/pactkit_opencode.egg-info/PKG-INFO +2 -2
  5. {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/src/pactkit_opencode.egg-info/SOURCES.txt +3 -0
  6. pactkit_opencode-2.27.0/src/pactkit_opencode.egg-info/requires.txt +1 -0
  7. pactkit_opencode-2.27.0/tests/test_a1_config_recovery.py +224 -0
  8. pactkit_opencode-2.27.0/tests/test_a2_mcp_retired.py +69 -0
  9. {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/tests/test_bug035_opencode_dual_layer.py +2 -25
  10. {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/tests/test_deploy_opencode_parity.py +32 -48
  11. pactkit_opencode-2.27.0/tests/test_host_body_snapshot.py +126 -0
  12. {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/tests/test_story069_opencode_format.py +3 -32
  13. {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/tests/test_story071_opencode_config_parity.py +3 -74
  14. {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/tests/test_story073_command_model_routing.py +2 -1
  15. {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/tests/test_story_work_units.py +2 -1
  16. pactkit_opencode-2.25.2/src/pactkit_opencode.egg-info/requires.txt +0 -1
  17. {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/README.md +0 -0
  18. {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/setup.cfg +0 -0
  19. {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/src/pactkit_opencode/__init__.py +0 -0
  20. {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/src/pactkit_opencode.egg-info/dependency_links.txt +0 -0
  21. {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/src/pactkit_opencode.egg-info/entry_points.txt +0 -0
  22. {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/src/pactkit_opencode.egg-info/top_level.txt +0 -0
  23. {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/tests/test_story070_opencode_compliance.py +0 -0
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pactkit-opencode
3
- Version: 2.25.2
3
+ Version: 2.27.0
4
4
  Summary: PactKit adapter for OpenCode IDE
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
7
- Requires-Dist: pactkit<2.26.0,>=2.25.2
7
+ Requires-Dist: pactkit<2.28.0,>=2.27.0
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pactkit-opencode"
7
- version = "2.25.2"
7
+ version = "2.27.0"
8
8
  description = "PactKit adapter for OpenCode IDE"
9
9
  requires-python = ">=3.10"
10
10
  license = "MIT"
11
11
  dependencies = [
12
- "pactkit>=2.25.2,<2.26.0",
12
+ "pactkit>=2.27.0,<2.28.0",
13
13
  ]
14
14
 
15
15
  [project.entry-points."pactkit.deployers"]
@@ -10,10 +10,10 @@ from pathlib import Path
10
10
  from pactkit import __version__
11
11
  from pactkit.config import (
12
12
  DEFAULT_RULE_IDS,
13
- activate_pactkit_maintainer_overlay,
14
13
  VALID_AGENTS,
15
14
  VALID_COMMANDS,
16
15
  VALID_SKILLS,
16
+ activate_pactkit_maintainer_overlay,
17
17
  auto_merge_config_file,
18
18
  load_config,
19
19
  )
@@ -35,6 +35,70 @@ from pactkit.utils import atomic_write
35
35
  OPENCODE_EXCLUDED_COMMANDS: frozenset[str] = frozenset()
36
36
 
37
37
 
38
+ def _instruction_ref_is_ours(opencode_root, ref) -> bool:
39
+ """Whether an instruction ref is provably PactKit's (R4, 2026-09-11 review).
40
+
41
+ 名称不构成所有权证据: a ref whose filename merely collides with a registry
42
+ id or legacy id (the user's own ``rules/08-architecture-principles.md``)
43
+ is NOT ours. Proven ownership requires one of:
44
+
45
+ - the exact legacy wildcard value PactKit once wrote (``rules/*.md``);
46
+ - a ref in the current managed core set (re-added after the filter);
47
+ - a ref recorded in the deployment manifest's ``opencode_instructions``
48
+ history (authoritative once any 2.26+ deploy has run);
49
+ - the referenced file still being byte-identical to managed content
50
+ PactKit deployed for that filename (legacy managed contents).
51
+ """
52
+ if not isinstance(ref, str):
53
+ return False
54
+ if ref == "rules/*.md":
55
+ return True
56
+ from pactkit.prompts.rules import LEGACY_RULE_CONTENTS, RULES_INSTRUCTIONS_CORE
57
+
58
+ if ref in RULES_INSTRUCTIONS_CORE:
59
+ return True
60
+ from pactkit.deploy_manifest import load_previous_manifest
61
+
62
+ recorded = load_previous_manifest(opencode_root).get("opencode_instructions")
63
+ if isinstance(recorded, list) and ref in recorded:
64
+ return True
65
+ import re
66
+
67
+ match = re.fullmatch(r"rules/([^/]+)\.md", ref)
68
+ if match is None:
69
+ return False
70
+ filename = f"{match.group(1)}.md"
71
+ expected = LEGACY_RULE_CONTENTS.get(filename)
72
+ if expected is None:
73
+ return False
74
+ path = Path(opencode_root) / "rules" / filename
75
+ if not path.is_file():
76
+ return False
77
+ try:
78
+ return path.read_text(encoding="utf-8") == expected
79
+ except OSError:
80
+ return False
81
+
82
+
83
+ def _ref_resembles_pactkit(ref) -> bool:
84
+ """Reporting-only heuristic: a ref that *looks* like a PactKit rule ref
85
+ (filename matches a registry id or legacy id) but failed the ownership
86
+ evidence. Used to explain preserved entries — never to remove them."""
87
+ import re
88
+
89
+ if not isinstance(ref, str):
90
+ return False
91
+ match = re.fullmatch(r"rules/([^/]+)\.md", ref)
92
+ if match is None:
93
+ return False
94
+ from pactkit.prompts.rules import RULE_DEFINITIONS
95
+
96
+ ids = {rule.id for rule in RULE_DEFINITIONS.values()}
97
+ for rule in RULE_DEFINITIONS.values():
98
+ ids.update(rule.legacy_ids)
99
+ return match.group(1) in ids
100
+
101
+
38
102
  class OpenCodeDeployer(DeployerBase):
39
103
  """OpenCode deployment — generate OpenCode-native configuration (STORY-069).
40
104
 
@@ -52,7 +116,9 @@ class OpenCodeDeployer(DeployerBase):
52
116
  - Calls auto_merge_config_file() for automatic config updates (R2)
53
117
  - Calls _cleanup_legacy() to remove stale skill files (R3)
54
118
  - Generates project-level AGENTS.md when not in preview mode (R4)
55
- - Prints MCP server recommendations (R5)
119
+ - Third-party MCP recommendations are retired (R5 superseded by
120
+ STORY-slim-20260911b2bbd79889e0 A2/R3: deploy neither installs,
121
+ registers, nor recommends external services)
56
122
  """
57
123
 
58
124
  profile = get_profile("opencode")
@@ -113,6 +179,16 @@ class OpenCodeDeployer(DeployerBase):
113
179
  if replaced != content:
114
180
  atomic_write(skill_md, replaced)
115
181
 
182
+ # HOTFIX-slim-20260903d464746b1909: post-pass rewrites deployed bytes in
183
+ # place; record the FINAL (transformed) digests or the next deploy cannot
184
+ # prove ownership and re-produces .pactkit-new candidates.
185
+ from pactkit.deploy_manifest import record_deployed_file
186
+
187
+ for skill_md in skills_dir.glob("*/SKILL.md"):
188
+ record_deployed_file(
189
+ opencode_root, skill_md.relative_to(opencode_root).as_posix(),
190
+ )
191
+
116
192
  # Deploy commands to commands/ directory (OpenCode uses /command-name to invoke)
117
193
  n_commands = _deploy_commands(commands_dir, enabled_commands, profile=self.profile, config=cfg)
118
194
 
@@ -123,10 +199,14 @@ class OpenCodeDeployer(DeployerBase):
123
199
  if replaced != content:
124
200
  atomic_write(cmd_md, replaced)
125
201
 
126
- _deploy_rules(opencode_root, enabled_rules, profile=self.profile)
127
- _deploy_guides(opencode_root, profile=self.profile)
128
- self._deploy_agents_md_inline(opencode_root)
202
+ for cmd_md in commands_dir.glob("*.md"):
203
+ record_deployed_file(
204
+ opencode_root, cmd_md.relative_to(opencode_root).as_posix(),
205
+ )
129
206
 
207
+ # R4 (2026-09-11 review): retire instruction refs BEFORE _deploy_rules
208
+ # deletes byte-identical legacy files — ref ownership is proven by the
209
+ # referenced file's content, so the files must still exist here.
130
210
  providers = self._load_opencode_providers(opencode_root)
131
211
  if not providers:
132
212
  providers = self._load_opencode_providers(
@@ -140,6 +220,10 @@ class OpenCodeDeployer(DeployerBase):
140
220
  enabled_commands=enabled_commands,
141
221
  )
142
222
 
223
+ _deploy_rules(opencode_root, enabled_rules, profile=self.profile)
224
+ _deploy_guides(opencode_root, profile=self.profile)
225
+ self._deploy_agents_md_inline(opencode_root)
226
+
143
227
  n_agents = _deploy_agents(agents_dir, enabled_agents, profile=self.profile)
144
228
 
145
229
  ci_config = cfg.get("ci", {})
@@ -160,8 +244,6 @@ class OpenCodeDeployer(DeployerBase):
160
244
  if target is None:
161
245
  self._generate_project_agents_md()
162
246
 
163
- self._print_mcp_recommendations_opencode()
164
-
165
247
  # --- OpenCode-specific helpers ---
166
248
 
167
249
  @staticmethod
@@ -200,45 +282,6 @@ class OpenCodeDeployer(DeployerBase):
200
282
  ]
201
283
  atomic_write(opencode_root / "AGENTS.md", "\n".join(lines))
202
284
 
203
- @staticmethod
204
- def _deploy_opencode_json(opencode_root):
205
- """Generate opencode.json project configuration (STORY-071 R1/R2)."""
206
- config = {
207
- "$schema": "https://opencode.ai/config.json",
208
- "instructions": ["AGENTS.md", "docs/product/context.md"],
209
- "agent": {
210
- "build": {"model": "inherit"},
211
- "plan": {"model": "inherit"},
212
- },
213
- "permission": {
214
- "edit": "allow",
215
- "bash": {
216
- "*": "allow",
217
- "rm -rf /*": "deny",
218
- "rm -rf /Users/*": "deny",
219
- "rm -rf /System/*": "deny",
220
- "sudo rm *": "deny",
221
- "sudo mkfs *": "deny",
222
- "curl * | sh": "deny",
223
- "wget * | sh": "deny",
224
- },
225
- "read": {
226
- "*": "allow",
227
- "*.env": "deny",
228
- "*.env.*": "deny",
229
- "*.env.example": "allow",
230
- },
231
- },
232
- "mcp": {
233
- "context7": {
234
- "type": "remote",
235
- "url": "https://mcp.context7.com/mcp",
236
- },
237
- },
238
- }
239
- content = json.dumps(config, indent=2, ensure_ascii=False) + "\n"
240
- atomic_write(opencode_root / "opencode.json", content)
241
-
242
285
  @staticmethod
243
286
  def _resolve_opencode_model_id(short_name, providers):
244
287
  """Resolve a short model name to a full provider/model-id (STORY-073)."""
@@ -268,33 +311,85 @@ class OpenCodeDeployer(DeployerBase):
268
311
  def _update_global_opencode_json(
269
312
  opencode_root, command_models=None, providers=None, enabled_commands=None
270
313
  ):
271
- """Update global opencode.json with instructions, command templates, and model routing."""
314
+ """Update global opencode.json with instructions, command templates, and model routing.
315
+
316
+ STORY-slim-20260911b2bbd79889e0 A1/R4 — three contracts:
317
+
318
+ - A file that cannot be parsed is left byte-identical with a
319
+ diagnostic. Falling back to an empty object would overwrite the
320
+ user's providers, models and MCP entries with nothing.
321
+ - An overwrite is preceded by a timestamped backup under
322
+ ``.pactkit-backups/`` (core's ``backup_existing``).
323
+ - Instruction entries are only removed when provably PactKit's own —
324
+ the legacy ``rules/*.md`` wildcard, a current managed core ref, a
325
+ manifest-recorded ref, or a ref whose file bytes still match managed
326
+ content (R4: a registry-id name collision is not ownership). A
327
+ user's own ``rules/my-team.md`` — or their own
328
+ ``rules/08-architecture-principles.md`` — must survive the update,
329
+ and preserved look-alikes are reported explicitly.
330
+ """
272
331
  json_path = opencode_root / "opencode.json"
273
332
  config = {}
274
333
 
275
334
  if json_path.is_file():
335
+ original_bytes = json_path.read_bytes()
276
336
  try:
277
- config = json.loads(json_path.read_text(encoding="utf-8"))
278
- except (json.JSONDecodeError, OSError):
279
- config = {}
337
+ config = json.loads(original_bytes.decode("utf-8"))
338
+ except (json.JSONDecodeError, UnicodeDecodeError):
339
+ print(
340
+ f" ⚠️ {json_path} is not valid JSON — left untouched; "
341
+ "fix or remove it manually, then re-run pactkit update"
342
+ )
343
+ return
344
+ except OSError as exc:
345
+ print(f" ⚠️ {json_path} is unreadable ({exc}) — left untouched")
346
+ return
347
+ if not isinstance(config, dict):
348
+ print(
349
+ f" ⚠️ {json_path} is not a JSON object — left untouched; "
350
+ "merge it manually if needed"
351
+ )
352
+ return
353
+ from pactkit.deploy_manifest import backup_existing
354
+
355
+ backup_existing(opencode_root, json_path)
280
356
 
281
357
  config.setdefault("$schema", "https://opencode.ai/config.json")
282
358
 
283
359
  # Always-load layer: only the self-contained Runtime Kernel is global.
284
360
  # Phase, command, conditional, and risk rules are activated by the
285
361
  # current command instead of through a wildcard instruction.
362
+ #
363
+ # R4 (2026-09-11 review, finding 1): retire only refs with ownership
364
+ # EVIDENCE — the legacy wildcard, managed core refs, a manifest
365
+ # record, or byte-identical managed file content. A name that merely
366
+ # collides with a registry id is the user's; preserved refs that
367
+ # resemble PactKit's are reported explicitly, never silently kept.
286
368
  from pactkit.prompts.rules import RULES_INSTRUCTIONS_CORE
287
369
 
288
- managed = set(RULES_INSTRUCTIONS_CORE)
289
- existing = [
290
- i
291
- for i in config.get("instructions", [])
292
- if i != "rules/*.md" and not (i.startswith("rules/") and i not in managed)
293
- ]
370
+ instructions = config.get("instructions", [])
371
+ if not isinstance(instructions, list):
372
+ instructions = []
373
+ kept: list = []
374
+ unproven_lookalikes: list[str] = []
375
+ for ref in instructions:
376
+ if isinstance(ref, str) and _instruction_ref_is_ours(opencode_root, ref):
377
+ continue # provably ours — retired (core refs re-added below)
378
+ kept.append(ref)
379
+ if _ref_resembles_pactkit(ref):
380
+ unproven_lookalikes.append(ref)
294
381
  for entry in RULES_INSTRUCTIONS_CORE:
295
- if entry not in existing:
296
- existing.append(entry)
297
- config["instructions"] = existing
382
+ if entry not in kept:
383
+ kept.append(entry)
384
+ config["instructions"] = kept
385
+ if unproven_lookalikes:
386
+ print(
387
+ f" ⚠️ preserved {len(unproven_lookalikes)} instruction ref(s) "
388
+ "that resemble PactKit's but are not provably ours (no manifest "
389
+ "record, and the file is absent or differs from managed "
390
+ f"content): {', '.join(unproven_lookalikes)} — remove manually "
391
+ "if you know they are PactKit's"
392
+ )
298
393
 
299
394
  # OpenCode auto-discovers commands from commands/*.md directory.
300
395
  # opencode.json "command" section requires BOTH "model" AND "template" fields.
@@ -314,28 +409,6 @@ class OpenCodeDeployer(DeployerBase):
314
409
  content = json.dumps(config, indent=2, ensure_ascii=False) + "\n"
315
410
  atomic_write(json_path, content)
316
411
 
317
- @staticmethod
318
- def _print_mcp_recommendations_opencode():
319
- """Print MCP server recommendations for OpenCode deployment (STORY-071 R4)."""
320
- print("\n📦 MCP Server Configuration (add to opencode.json):")
321
- print()
322
- print(" Remote MCP (no install needed):")
323
- print(' "context7": { "type": "remote", "url": "https://mcp.context7.com/mcp" }')
324
- print()
325
- print(" Local MCP (requires npx):")
326
- print(
327
- ' "memory": { "type": "local", "command": '
328
- '["npx", "-y", "@modelcontextprotocol/server-memory"] }'
329
- )
330
- print(
331
- ' "playwright": { "type": "local", "command": '
332
- '["npx", "-y", "@playwright/mcp"] }'
333
- )
334
- print(
335
- ' "puppeteer": { "type": "local", "command": '
336
- '["npx", "-y", "@anthropic/mcp-puppeteer"] }'
337
- )
338
-
339
412
  @staticmethod
340
413
  def _cleanup_command_skills(skills_dir):
341
414
  """Remove legacy command skills from skills/ directory.
@@ -368,7 +441,8 @@ _CLI_TO_SCRIPT = [
368
441
  # scaffold
369
442
  ("Run `python3 ~/.config/opencode/skills/pactkit-scaffold/scripts/scaffold.py", f"Run `{_SCAFFOLD_SCRIPT}"),
370
443
  # pactkit CLI → manual fallback hint
371
- ("Run `pactkit clean`", "Run language-specific cleanup (e.g., `find . -name '__pycache__' -exec rm -rf {} +` for Python)"),
444
+ ("Run `pactkit clean`",
445
+ "Run language-specific cleanup (e.g., `find . -name '__pycache__' -exec rm -rf {} +`)"),
372
446
  ]
373
447
 
374
448
 
@@ -1,7 +1,7 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pactkit-opencode
3
- Version: 2.25.2
3
+ Version: 2.27.0
4
4
  Summary: PactKit adapter for OpenCode IDE
5
5
  License-Expression: MIT
6
6
  Requires-Python: >=3.10
7
- Requires-Dist: pactkit<2.26.0,>=2.25.2
7
+ Requires-Dist: pactkit<2.28.0,>=2.27.0
@@ -8,8 +8,11 @@ src/pactkit_opencode.egg-info/dependency_links.txt
8
8
  src/pactkit_opencode.egg-info/entry_points.txt
9
9
  src/pactkit_opencode.egg-info/requires.txt
10
10
  src/pactkit_opencode.egg-info/top_level.txt
11
+ tests/test_a1_config_recovery.py
12
+ tests/test_a2_mcp_retired.py
11
13
  tests/test_bug035_opencode_dual_layer.py
12
14
  tests/test_deploy_opencode_parity.py
15
+ tests/test_host_body_snapshot.py
13
16
  tests/test_story069_opencode_format.py
14
17
  tests/test_story070_opencode_compliance.py
15
18
  tests/test_story071_opencode_config_parity.py
@@ -0,0 +1 @@
1
+ pactkit<2.28.0,>=2.27.0
@@ -0,0 +1,224 @@
1
+ """Recoverable config updates for the user's opencode.json (A1).
2
+
3
+ STORY-slim-20260911b2bbd79889e0 A1/R4. Three failure modes were replayed at
4
+ core HEAD 9faaf8d by reading `_update_global_opencode_json`:
5
+
6
+ 1. Parse failure fell back to ``config = {}`` and overwrote the user's file
7
+ with an essentially empty object — every provider, model and MCP entry gone.
8
+ 2. The overwrite wrote directly with no backup — nothing to restore from.
9
+ 3. The instructions filter dropped every ``rules/`` entry not in PactKit's
10
+ managed set — a user's own ``rules/my-team.md`` reference included.
11
+
12
+ These tests pin the contract the umbrella spec demands: ownership first,
13
+ recoverable writes, user content preserved.
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ from pathlib import Path
20
+
21
+ from pactkit_opencode.deployer import OpenCodeDeployer
22
+
23
+ from pactkit.deploy_manifest import BACKUPS_DIR
24
+
25
+ USER_CONFIG = {
26
+ "$schema": "https://opencode.ai/config.json",
27
+ "instructions": ["rules/my-team.md"],
28
+ "provider": {
29
+ "kimi": {"npm": "@ai-sdk/openai-compatible",
30
+ "options": {"baseURL": "https://example.invalid/moonshot",
31
+ "apiKey": "sk-test-not-a-real-key"}},
32
+ },
33
+ "mcp_servers": {"memory": {"type": "local", "enabled": False}},
34
+ }
35
+
36
+
37
+ def _write_user_config(root: Path, payload) -> Path:
38
+ path = root / "opencode.json"
39
+ path.write_text(
40
+ payload if isinstance(payload, str) else json.dumps(payload, indent=2),
41
+ encoding="utf-8",
42
+ )
43
+ return path
44
+
45
+
46
+ class TestParseFailure:
47
+ def test_corrupt_config_is_left_byte_identical(self, tmp_path):
48
+ corrupt = '{"instructions": [truncated'
49
+ path = _write_user_config(tmp_path, corrupt)
50
+
51
+ OpenCodeDeployer._update_global_opencode_json(tmp_path)
52
+
53
+ assert path.read_text(encoding="utf-8") == corrupt
54
+
55
+ def test_corrupt_config_produces_a_diagnostic(self, tmp_path, capsys):
56
+ _write_user_config(tmp_path, '{"instructions": [truncated')
57
+
58
+ OpenCodeDeployer._update_global_opencode_json(tmp_path)
59
+
60
+ out = capsys.readouterr().out
61
+ assert "opencode.json" in out
62
+ assert "left untouched" in out or "untouched" in out
63
+
64
+ def test_no_empty_object_is_written(self, tmp_path):
65
+ _write_user_config(tmp_path, 'not json at all')
66
+
67
+ OpenCodeDeployer._update_global_opencode_json(tmp_path)
68
+
69
+ text = (tmp_path / "opencode.json").read_text(encoding="utf-8")
70
+ assert text == "not json at all"
71
+
72
+
73
+ class TestBackup:
74
+ def test_valid_update_backs_up_the_original(self, tmp_path):
75
+ path = _write_user_config(tmp_path, USER_CONFIG)
76
+
77
+ OpenCodeDeployer._update_global_opencode_json(tmp_path)
78
+
79
+ backups = list((tmp_path / BACKUPS_DIR).rglob("opencode.json"))
80
+ assert backups, "no backup was taken before overwriting"
81
+ assert backups[0].read_text(encoding="utf-8") == path.read_text(
82
+ encoding="utf-8"
83
+ ) or json.loads(backups[0].read_text(encoding="utf-8")) == USER_CONFIG
84
+
85
+ def test_missing_config_takes_no_backup(self, tmp_path):
86
+ OpenCodeDeployer._update_global_opencode_json(tmp_path)
87
+
88
+ assert not (tmp_path / BACKUPS_DIR).exists()
89
+
90
+
91
+ class TestUserContentPreserved:
92
+ def _update(self, tmp_path) -> dict:
93
+ _write_user_config(tmp_path, USER_CONFIG)
94
+ OpenCodeDeployer._update_global_opencode_json(tmp_path)
95
+ return json.loads((tmp_path / "opencode.json").read_text(encoding="utf-8"))
96
+
97
+ def test_user_rule_references_survive(self, tmp_path):
98
+ updated = self._update(tmp_path)
99
+
100
+ assert "rules/my-team.md" in updated["instructions"], (
101
+ "a user-authored rule reference was dropped by the update"
102
+ )
103
+
104
+ def test_providers_survive(self, tmp_path):
105
+ updated = self._update(tmp_path)
106
+
107
+ assert updated["provider"] == USER_CONFIG["provider"]
108
+
109
+ def test_user_mcp_servers_survive(self, tmp_path):
110
+ updated = self._update(tmp_path)
111
+
112
+ assert updated["mcp_servers"] == USER_CONFIG["mcp_servers"]
113
+
114
+ def test_managed_instructions_are_still_added(self, tmp_path):
115
+ updated = self._update(tmp_path)
116
+
117
+ from pactkit.prompts.rules import RULES_INSTRUCTIONS_CORE
118
+
119
+ for entry in RULES_INSTRUCTIONS_CORE:
120
+ assert entry in updated["instructions"]
121
+
122
+ def test_wildcard_rule_glob_is_still_retired(self, tmp_path):
123
+ """The legacy `rules/*.md` wildcard is provably PactKit's — retiring
124
+ it stays; only unknown user entries gain protection."""
125
+ payload = {"instructions": ["rules/*.md", "rules/my-team.md"]}
126
+ _write_user_config(tmp_path, payload)
127
+
128
+ OpenCodeDeployer._update_global_opencode_json(tmp_path)
129
+ updated = json.loads((tmp_path / "opencode.json").read_text(encoding="utf-8"))
130
+
131
+ assert "rules/*.md" not in updated["instructions"]
132
+ assert "rules/my-team.md" in updated["instructions"]
133
+
134
+
135
+ class TestOwnershipByEvidence:
136
+ """2026-09-11 review, finding 1 (opencode side): the instruction filter
137
+ still retired refs by NAME — a registry legacy id colliding with the
138
+ user's own file name was enough to delete their reference.
139
+
140
+ R4: 名称不构成所有权证据. A ref is PactKit's only with trusted evidence:
141
+ the exact legacy wildcard value, a current managed ref, a manifest record,
142
+ or the referenced file's bytes still matching managed content.
143
+ """
144
+
145
+ def test_user_authored_legacy_named_ref_survives(self, tmp_path, capsys):
146
+ """Reviewer probe: the user's own rules/08-architecture-principles.md
147
+ (a registry legacy id, user-authored content) keeps its ref."""
148
+ (tmp_path / "rules").mkdir()
149
+ (tmp_path / "rules" / "08-architecture-principles.md").write_text(
150
+ "# Our architecture principles\n- ours, not PactKit's\n", encoding="utf-8",
151
+ )
152
+ _write_user_config(
153
+ tmp_path, {"instructions": ["rules/08-architecture-principles.md"]},
154
+ )
155
+
156
+ OpenCodeDeployer._update_global_opencode_json(tmp_path)
157
+
158
+ updated = json.loads((tmp_path / "opencode.json").read_text(encoding="utf-8"))
159
+ assert "rules/08-architecture-principles.md" in updated["instructions"], (
160
+ "a user-authored rule reference was retired by name collision"
161
+ )
162
+ assert "08-architecture-principles" in capsys.readouterr().out, (
163
+ "the preserved ref must be reported (R4: 有明确说明), not silent"
164
+ )
165
+
166
+ def test_manifest_recorded_ref_is_retired(self, tmp_path):
167
+ """Positive control: a ref the deployment manifest records as ours is
168
+ retired even when the file no longer exists to content-verify."""
169
+ from pactkit.deploy_manifest import MANIFEST_NAME
170
+
171
+ (tmp_path / MANIFEST_NAME).write_text(
172
+ json.dumps({"opencode_instructions": ["rules/old-managed.md"]}),
173
+ encoding="utf-8",
174
+ )
175
+ _write_user_config(tmp_path, {"instructions": ["rules/old-managed.md"]})
176
+
177
+ OpenCodeDeployer._update_global_opencode_json(tmp_path)
178
+
179
+ updated = json.loads((tmp_path / "opencode.json").read_text(encoding="utf-8"))
180
+ assert "rules/old-managed.md" not in updated["instructions"]
181
+
182
+ def test_legacy_content_matching_ref_is_retired(self, tmp_path):
183
+ """Positive control: the file's bytes still equal the managed content
184
+ PactKit deployed for that filename — ours, retired."""
185
+ from pactkit.prompts.rules import LEGACY_RULE_CONTENTS
186
+
187
+ filename = sorted(LEGACY_RULE_CONTENTS)[0]
188
+ (tmp_path / "rules").mkdir()
189
+ (tmp_path / "rules" / filename).write_text(
190
+ LEGACY_RULE_CONTENTS[filename], encoding="utf-8",
191
+ )
192
+ _write_user_config(tmp_path, {"instructions": [f"rules/{filename}"]})
193
+
194
+ OpenCodeDeployer._update_global_opencode_json(tmp_path)
195
+
196
+ updated = json.loads((tmp_path / "opencode.json").read_text(encoding="utf-8"))
197
+ assert f"rules/{filename}" not in updated["instructions"]
198
+
199
+ def test_modified_legacy_content_ref_survives(self, tmp_path):
200
+ """Same filename, user-edited bytes — the ref is preserved."""
201
+ from pactkit.prompts.rules import LEGACY_RULE_CONTENTS
202
+
203
+ filename = sorted(LEGACY_RULE_CONTENTS)[0]
204
+ (tmp_path / "rules").mkdir()
205
+ (tmp_path / "rules" / filename).write_text(
206
+ LEGACY_RULE_CONTENTS[filename] + "\n# user override\n", encoding="utf-8",
207
+ )
208
+ _write_user_config(tmp_path, {"instructions": [f"rules/{filename}"]})
209
+
210
+ OpenCodeDeployer._update_global_opencode_json(tmp_path)
211
+
212
+ updated = json.loads((tmp_path / "opencode.json").read_text(encoding="utf-8"))
213
+ assert f"rules/{filename}" in updated["instructions"]
214
+
215
+
216
+ class TestIdempotence:
217
+ def test_repeated_updates_are_stable(self, tmp_path):
218
+ _write_user_config(tmp_path, USER_CONFIG)
219
+ OpenCodeDeployer._update_global_opencode_json(tmp_path)
220
+ first = (tmp_path / "opencode.json").read_text(encoding="utf-8")
221
+
222
+ OpenCodeDeployer._update_global_opencode_json(tmp_path)
223
+
224
+ assert (tmp_path / "opencode.json").read_text(encoding="utf-8") == first
@@ -0,0 +1,69 @@
1
+ """MCP injection surfaces are retired (STORY-slim-20260911b2bbd79889e0 A2/R3).
2
+
3
+ Two dead-connection templates lived in this adapter:
4
+
5
+ - ``_deploy_opencode_json`` — a full opencode.json generator embedding a
6
+ ``context7`` MCP server entry. It had **no production caller** (verified:
7
+ only tests referenced it), so nothing on a user's machine depends on it —
8
+ but leaving it in place is leaving a loaded injection template for the
9
+ next refactor to wire back up.
10
+ - ``_print_mcp_recommendations_opencode`` — printed a Context7 registration
11
+ snippet after every deploy (R3: 部署不输出 MCP 安装推荐).
12
+
13
+ A deploy now neither recommends nor references third-party servers. Optional
14
+ tools the user configures themselves remain usable — that is the host's
15
+ business, not the deployer's.
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import json
21
+
22
+ from pactkit_opencode.deployer import OpenCodeDeployer
23
+
24
+
25
+ class TestDeadTemplatesRemoved:
26
+ def test_mcp_injection_template_is_gone(self):
27
+ """The dead generator must not ship — dead code with a live MCP
28
+ payload is a future injection waiting to be wired up."""
29
+ assert not hasattr(OpenCodeDeployer, "_deploy_opencode_json")
30
+
31
+ def test_mcp_recommendations_are_gone(self):
32
+ assert not hasattr(OpenCodeDeployer, "_print_mcp_recommendations_opencode")
33
+
34
+ def test_deployer_source_has_no_context7_reference(self):
35
+ import inspect
36
+
37
+ source = inspect.getsource(OpenCodeDeployer)
38
+ assert "context7" not in source.lower()
39
+ assert "mcp.context7.com" not in source
40
+
41
+
42
+ class TestDeployOutputClean:
43
+ def test_deploy_prints_no_mcp_recommendation(self, tmp_path, capsys):
44
+ OpenCodeDeployer().deploy(target=str(tmp_path))
45
+
46
+ out = capsys.readouterr().out
47
+ # Assert on recommendation phrasing, not the bare word "mcp" — the
48
+ # output legitimately contains the target path, and this test's own
49
+ # tmp dir name contains "mcp" (substring false positive, same class
50
+ # as the pytest-of-slim lesson in the project's Key Decisions).
51
+ assert "context7" not in out.lower()
52
+ assert "recommended mcp" not in out.lower()
53
+ assert "mcp_servers" not in out.lower()
54
+ assert "mcp.context7.com" not in out.lower()
55
+
56
+ def test_global_config_update_mentions_no_mcp(self, tmp_path, capsys):
57
+ (tmp_path / "opencode.json").write_text(
58
+ json.dumps({"instructions": []}), encoding="utf-8"
59
+ )
60
+
61
+ OpenCodeDeployer._update_global_opencode_json(tmp_path)
62
+
63
+ out = capsys.readouterr().out
64
+ assert "mcp_servers" not in out.lower()
65
+ assert "context7" not in out.lower()
66
+ # The user's file must not gain an mcp_servers key either.
67
+ data = json.loads((tmp_path / "opencode.json").read_text(encoding="utf-8"))
68
+ assert "mcp_servers" not in data
69
+ assert "mcp" not in data
@@ -4,8 +4,8 @@ Tests for dual-layer deployment: global (pactkit init) vs project (/project-init
4
4
  """
5
5
 
6
6
 
7
+
7
8
  from pactkit.generators.deployer import deploy
8
- from pactkit_opencode.deployer import OpenCodeDeployer
9
9
 
10
10
  # ===========================================================================
11
11
  # R1: Global deployment does NOT generate opencode.json
@@ -96,27 +96,4 @@ class TestR3ProjectInitOpencodeDetection:
96
96
  # ===========================================================================
97
97
 
98
98
 
99
- class TestR5OpencodeJsonStructure:
100
- """R5: _deploy_opencode_json helper produces correct structure."""
101
-
102
- def test_opencode_json_helper_exists(self):
103
- """_deploy_opencode_json function still exists for project-init use."""
104
- assert callable(OpenCodeDeployer._deploy_opencode_json)
105
-
106
- def test_opencode_json_has_schema(self, tmp_path):
107
- """opencode.json contains $schema field."""
108
- import json
109
-
110
- OpenCodeDeployer._deploy_opencode_json(tmp_path)
111
- data = json.loads((tmp_path / "opencode.json").read_text())
112
- assert "$schema" in data
113
- assert "opencode.ai" in data["$schema"]
114
-
115
- def test_opencode_json_has_instructions(self, tmp_path):
116
- """opencode.json contains instructions field."""
117
- import json
118
-
119
- OpenCodeDeployer._deploy_opencode_json(tmp_path)
120
- data = json.loads((tmp_path / "opencode.json").read_text())
121
- assert "instructions" in data
122
- assert isinstance(data["instructions"], list)
99
+ # Retired with STORY-slim-20260911b2bbd79889e0 A2/R3: _deploy_opencode_json is deleted (dead MCP-injection template).
@@ -15,7 +15,6 @@ from pathlib import Path
15
15
  from unittest.mock import patch
16
16
 
17
17
  import yaml
18
-
19
18
  from pactkit_opencode.deployer import OpenCodeDeployer
20
19
 
21
20
  _PROJECT_ROOT = Path(__file__).parent.parent.parent
@@ -54,10 +53,9 @@ class TestOpenCodeSelectiveDeploy:
54
53
 
55
54
  with patch("pactkit_opencode.deployer.auto_merge_config_file", return_value=[]):
56
55
  with patch.object(OpenCodeDeployer, "_generate_project_agents_md"):
57
- with patch.object(OpenCodeDeployer, "_print_mcp_recommendations_opencode"):
58
- with patch.object(OpenCodeDeployer, "_load_opencode_providers", return_value={}):
59
- with patch.object(OpenCodeDeployer, "_update_global_opencode_json"):
60
- OpenCodeDeployer().deploy(target=output_path)
56
+ with patch.object(OpenCodeDeployer, "_load_opencode_providers", return_value={}):
57
+ with patch.object(OpenCodeDeployer, "_update_global_opencode_json"):
58
+ OpenCodeDeployer().deploy(target=output_path)
61
59
 
62
60
  # Resolve macOS /var → /private/var symlink
63
61
  agents_dir = Path(output_path).resolve() / "agents"
@@ -73,11 +71,10 @@ class TestOpenCodeSelectiveDeploy:
73
71
 
74
72
  # No pactkit.yaml — no .opencode dir either
75
73
  with patch.object(OpenCodeDeployer, "_generate_project_agents_md"):
76
- with patch.object(OpenCodeDeployer, "_print_mcp_recommendations_opencode"):
77
- with patch.object(OpenCodeDeployer, "_load_opencode_providers", return_value={}):
78
- with patch.object(OpenCodeDeployer, "_update_global_opencode_json"):
79
- with patch("pactkit_opencode.deployer.Path.cwd", return_value=tmp_path):
80
- OpenCodeDeployer().deploy(target=str(tmp_path / "output"))
74
+ with patch.object(OpenCodeDeployer, "_load_opencode_providers", return_value={}):
75
+ with patch.object(OpenCodeDeployer, "_update_global_opencode_json"):
76
+ with patch("pactkit_opencode.deployer.Path.cwd", return_value=tmp_path):
77
+ OpenCodeDeployer().deploy(target=str(tmp_path / "output"))
81
78
 
82
79
  agents_dir = tmp_path / "output" / "agents"
83
80
  deployed_agents = list(agents_dir.glob("*.md")) if agents_dir.exists() else []
@@ -116,11 +113,10 @@ class TestOpenCodeAutoMerge:
116
113
  with patch("pactkit_opencode.deployer.auto_merge_config_file") as mock_merge:
117
114
  mock_merge.return_value = []
118
115
  with patch.object(OpenCodeDeployer, "_generate_project_agents_md"):
119
- with patch.object(OpenCodeDeployer, "_print_mcp_recommendations_opencode"):
120
- with patch.object(OpenCodeDeployer, "_load_opencode_providers", return_value={}):
121
- with patch.object(OpenCodeDeployer, "_update_global_opencode_json"):
122
- with patch("pactkit_opencode.deployer.Path.cwd", return_value=tmp_path):
123
- OpenCodeDeployer().deploy(target=str(tmp_path / "output"))
116
+ with patch.object(OpenCodeDeployer, "_load_opencode_providers", return_value={}):
117
+ with patch.object(OpenCodeDeployer, "_update_global_opencode_json"):
118
+ with patch("pactkit_opencode.deployer.Path.cwd", return_value=tmp_path):
119
+ OpenCodeDeployer().deploy(target=str(tmp_path / "output"))
124
120
 
125
121
  mock_merge.assert_called_once()
126
122
 
@@ -129,11 +125,10 @@ class TestOpenCodeAutoMerge:
129
125
  with patch("pactkit_opencode.deployer.auto_merge_config_file") as mock_merge:
130
126
  mock_merge.return_value = []
131
127
  with patch.object(OpenCodeDeployer, "_generate_project_agents_md"):
132
- with patch.object(OpenCodeDeployer, "_print_mcp_recommendations_opencode"):
133
- with patch.object(OpenCodeDeployer, "_load_opencode_providers", return_value={}):
134
- with patch.object(OpenCodeDeployer, "_update_global_opencode_json"):
135
- with patch("pactkit_opencode.deployer.Path.cwd", return_value=tmp_path):
136
- OpenCodeDeployer().deploy(target=str(tmp_path / "output"))
128
+ with patch.object(OpenCodeDeployer, "_load_opencode_providers", return_value={}):
129
+ with patch.object(OpenCodeDeployer, "_update_global_opencode_json"):
130
+ with patch("pactkit_opencode.deployer.Path.cwd", return_value=tmp_path):
131
+ OpenCodeDeployer().deploy(target=str(tmp_path / "output"))
137
132
  # auto_merge not called when no yaml found
138
133
  mock_merge.assert_not_called()
139
134
 
@@ -149,11 +144,10 @@ class TestOpenCodeCleanupLegacy:
149
144
  def test_cleanup_legacy_called(self, tmp_path):
150
145
  with patch("pactkit_opencode.deployer._cleanup_legacy") as mock_cleanup:
151
146
  with patch.object(OpenCodeDeployer, "_generate_project_agents_md"):
152
- with patch.object(OpenCodeDeployer, "_print_mcp_recommendations_opencode"):
153
- with patch.object(OpenCodeDeployer, "_load_opencode_providers", return_value={}):
154
- with patch.object(OpenCodeDeployer, "_update_global_opencode_json"):
155
- with patch("pactkit_opencode.deployer.Path.cwd", return_value=tmp_path):
156
- OpenCodeDeployer().deploy(target=str(tmp_path / "output"))
147
+ with patch.object(OpenCodeDeployer, "_load_opencode_providers", return_value={}):
148
+ with patch.object(OpenCodeDeployer, "_update_global_opencode_json"):
149
+ with patch("pactkit_opencode.deployer.Path.cwd", return_value=tmp_path):
150
+ OpenCodeDeployer().deploy(target=str(tmp_path / "output"))
157
151
  mock_cleanup.assert_called_once()
158
152
 
159
153
 
@@ -198,26 +192,24 @@ class TestGenerateProjectAgentsMd:
198
192
  def test_called_from_deploy_opencode_when_no_target(self, tmp_path):
199
193
  """_generate_project_agents_md is called only when target is None."""
200
194
  with patch.object(OpenCodeDeployer, "_generate_project_agents_md") as mock_gen:
201
- with patch.object(OpenCodeDeployer, "_print_mcp_recommendations_opencode"):
202
- with patch.object(OpenCodeDeployer, "_load_opencode_providers", return_value={}):
203
- with patch.object(OpenCodeDeployer, "_update_global_opencode_json"):
204
- with patch("pactkit_opencode.deployer.Path.cwd", return_value=tmp_path):
205
- # With explicit target: should NOT call generate
206
- OpenCodeDeployer().deploy(target=str(tmp_path / "output"))
195
+ with patch.object(OpenCodeDeployer, "_load_opencode_providers", return_value={}):
196
+ with patch.object(OpenCodeDeployer, "_update_global_opencode_json"):
197
+ with patch("pactkit_opencode.deployer.Path.cwd", return_value=tmp_path):
198
+ # With explicit target: should NOT call generate
199
+ OpenCodeDeployer().deploy(target=str(tmp_path / "output"))
207
200
  mock_gen.assert_not_called()
208
201
 
209
202
  def test_called_from_deploy_opencode_without_target(self, tmp_path):
210
203
  """When target is None, _generate_project_agents_md IS called."""
211
204
  with patch.object(OpenCodeDeployer, "_generate_project_agents_md") as mock_gen:
212
- with patch.object(OpenCodeDeployer, "_print_mcp_recommendations_opencode"):
213
- with patch.object(OpenCodeDeployer, "_load_opencode_providers", return_value={}):
214
- with patch.object(OpenCodeDeployer, "_update_global_opencode_json"):
215
- with patch("pactkit_opencode.deployer.Path") as MockPath:
216
- MockPath.side_effect = Path
217
- MockPath.home.return_value = Path.home()
218
- MockPath.cwd.return_value = tmp_path
219
- # Pass target so we don't write to real ~/.config/opencode
220
- OpenCodeDeployer().deploy(target=str(tmp_path / "output"))
205
+ with patch.object(OpenCodeDeployer, "_load_opencode_providers", return_value={}):
206
+ with patch.object(OpenCodeDeployer, "_update_global_opencode_json"):
207
+ with patch("pactkit_opencode.deployer.Path") as MockPath:
208
+ MockPath.side_effect = Path
209
+ MockPath.home.return_value = Path.home()
210
+ MockPath.cwd.return_value = tmp_path
211
+ # Pass target so we don't write to real ~/.config/opencode
212
+ OpenCodeDeployer().deploy(target=str(tmp_path / "output"))
221
213
  # With explicit target, not called
222
214
  mock_gen.assert_not_called()
223
215
 
@@ -251,14 +243,6 @@ class TestDeployChainParity:
251
243
  src = self._get_opencode_source()
252
244
  assert "_generate_project_agents_md" in src
253
245
 
254
- def test_opencode_calls_print_mcp_recommendations(self):
255
- src = self._get_opencode_source()
256
- assert "mcp_recommendations" in src or "_print_mcp_recommendations" in src
257
-
258
-
259
- # ---------------------------------------------------------------------------
260
- # R7: _generate_config_if_missing format param
261
- # ---------------------------------------------------------------------------
262
246
 
263
247
 
264
248
  class TestGenerateConfigIfMissing:
@@ -0,0 +1,126 @@
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)
@@ -3,12 +3,10 @@ STORY-069: OpenCode Deployment Format Support
3
3
  Tests for `pactkit init --format opencode` deployment mode.
4
4
  """
5
5
 
6
- import json
7
6
  from unittest.mock import patch
8
7
 
9
8
  from pactkit.config import VALID_AGENTS, VALID_COMMANDS, VALID_FORMATS, VALID_SKILLS
10
9
  from pactkit.generators.deployer import deploy
11
- from pactkit_opencode.deployer import OpenCodeDeployer
12
10
 
13
11
  # OpenCode deploys commands to commands/{name}.md (invoked via /command-name)
14
12
  # and embedded skills to skills/{name}/SKILL.md (auto-discovered by AI agent).
@@ -230,36 +228,9 @@ class TestR5SkillsPathRewriting:
230
228
  # ===========================================================================
231
229
 
232
230
 
233
- class TestR6OpenCodeJsonGeneration:
234
- """R6: opencode.json helper has correct structure (BUG-035: project-level file)."""
235
-
236
- def test_opencode_json_has_schema(self, tmp_path):
237
- """opencode.json contains $schema field."""
238
- OpenCodeDeployer._deploy_opencode_json(tmp_path)
239
- data = json.loads((tmp_path / "opencode.json").read_text())
240
- assert "$schema" in data
241
- assert "opencode.ai" in data["$schema"]
242
-
243
- def test_opencode_json_has_instructions(self, tmp_path):
244
- """opencode.json contains instructions field."""
245
- OpenCodeDeployer._deploy_opencode_json(tmp_path)
246
- data = json.loads((tmp_path / "opencode.json").read_text())
247
- assert "instructions" in data
248
- assert isinstance(data["instructions"], list)
249
-
250
- def test_opencode_json_no_api_key(self, tmp_path):
251
- """opencode.json does NOT contain apiKey or provider secrets."""
252
- OpenCodeDeployer._deploy_opencode_json(tmp_path)
253
- content = (tmp_path / "opencode.json").read_text()
254
- assert "apiKey" not in content
255
- assert "api_key" not in content
256
- assert "API_KEY" not in content
257
-
258
-
259
- # ===========================================================================
260
- # R11: CLI --format opencode
261
- # ===========================================================================
262
-
231
+ # Retired with STORY-slim-20260911b2bbd79889e0 A2/R3: _deploy_opencode_json
232
+ # (the dead MCP-injection template) is deleted; the live global-config path
233
+ # is covered by test_a1_config_recovery + test_a2_mcp_retired.
263
234
 
264
235
  class TestR11CliFormatOpenCode:
265
236
  """R11: CLI --format includes opencode option."""
@@ -6,88 +6,17 @@ Tests for rules splitting, permission config, MCP template, and global opencode.
6
6
  import json
7
7
 
8
8
  from pactkit.generators.deployer import deploy
9
- from pactkit_opencode.deployer import OpenCodeDeployer
10
9
 
11
10
  # ===========================================================================
12
11
  # AC1: opencode.json (project-level) contains permission
13
12
  # ===========================================================================
14
13
 
15
14
 
16
- class TestAC1Permission:
17
- """AC1: _deploy_opencode_json generates permission config."""
15
+ # Retired with STORY-slim-20260911b2bbd79889e0 A2/R3: _deploy_opencode_json is deleted (dead MCP-injection template).
18
16
 
19
- def test_has_permission_field(self, tmp_path):
20
- """opencode.json contains 'permission' key."""
21
- OpenCodeDeployer._deploy_opencode_json(tmp_path)
22
- data = json.loads((tmp_path / "opencode.json").read_text())
23
- assert "permission" in data
24
-
25
- def test_bash_deny_rules(self, tmp_path):
26
- """permission.bash contains dangerous command deny rules."""
27
- OpenCodeDeployer._deploy_opencode_json(tmp_path)
28
- data = json.loads((tmp_path / "opencode.json").read_text())
29
- bash_perms = data["permission"]["bash"]
30
- assert bash_perms.get("rm -rf /*") == "deny"
31
- assert bash_perms.get("sudo rm *") == "deny"
32
- assert bash_perms.get("curl * | sh") == "deny"
33
-
34
- def test_env_file_deny(self, tmp_path):
35
- """permission.read denies .env files."""
36
- OpenCodeDeployer._deploy_opencode_json(tmp_path)
37
- data = json.loads((tmp_path / "opencode.json").read_text())
38
- read_perms = data["permission"]["read"]
39
- assert read_perms.get("*.env") == "deny"
40
- assert read_perms.get("*.env.example") == "allow"
41
-
42
-
43
- # ===========================================================================
44
- # AC2: opencode.json (project-level) contains MCP template
45
- # ===========================================================================
46
-
47
-
48
- class TestAC2MCP:
49
- """AC2: _deploy_opencode_json generates MCP config template."""
50
-
51
- def test_has_mcp_field(self, tmp_path):
52
- """opencode.json contains 'mcp' key."""
53
- OpenCodeDeployer._deploy_opencode_json(tmp_path)
54
- data = json.loads((tmp_path / "opencode.json").read_text())
55
- assert "mcp" in data
56
-
57
- def test_context7_remote(self, tmp_path):
58
- """mcp.context7 exists with type=remote."""
59
- OpenCodeDeployer._deploy_opencode_json(tmp_path)
60
- data = json.loads((tmp_path / "opencode.json").read_text())
61
- ctx7 = data["mcp"]["context7"]
62
- assert ctx7["type"] == "remote"
63
- assert "context7.com" in ctx7["url"]
64
-
65
-
66
- # ===========================================================================
67
- # AC3: opencode.json preserves existing fields
68
- # ===========================================================================
69
-
70
-
71
- class TestAC3PreserveFields:
72
- """AC3: opencode.json still has $schema and instructions."""
73
-
74
- def test_has_schema(self, tmp_path):
75
- """opencode.json contains $schema."""
76
- OpenCodeDeployer._deploy_opencode_json(tmp_path)
77
- data = json.loads((tmp_path / "opencode.json").read_text())
78
- assert "$schema" in data
79
-
80
- def test_has_instructions(self, tmp_path):
81
- """opencode.json contains instructions."""
82
- OpenCodeDeployer._deploy_opencode_json(tmp_path)
83
- data = json.loads((tmp_path / "opencode.json").read_text())
84
- assert "instructions" in data
85
-
86
-
87
- # ===========================================================================
88
- # AC4: project-init playbook mentions pactkit.yaml location
89
- # ===========================================================================
17
+ # Retired with STORY-slim-20260911b2bbd79889e0 A2/R3: the MCP template never shipped to users (no production caller).
90
18
 
19
+ # Retired with STORY-slim-20260911b2bbd79889e0 A2/R3: pinned the deleted template's fields.
91
20
 
92
21
  class TestAC4PlaybookPactkitYaml:
93
22
  """AC4: project-init playbook explains pactkit.yaml stays in .claude/ (classic) or .opencode/ (OpenCode)."""
@@ -5,9 +5,10 @@ Tests for command model routing (in opencode.json), project-init conditional, YA
5
5
 
6
6
  import json
7
7
 
8
+ from pactkit_opencode.deployer import OpenCodeDeployer
9
+
8
10
  from pactkit.config import get_default_config
9
11
  from pactkit.generators.deployer import deploy
10
- from pactkit_opencode.deployer import OpenCodeDeployer
11
12
 
12
13
  _resolve_opencode_model_id = OpenCodeDeployer._resolve_opencode_model_id
13
14
 
@@ -1,9 +1,10 @@
1
1
  import json
2
2
 
3
+ from pactkit_opencode.deployer import OpenCodeDeployer
4
+
3
5
  from pactkit.generators.deployer import _render_skill_md
4
6
  from pactkit.portable_methods import get_portable_methods
5
7
  from pactkit.profiles import get_profile
6
- from pactkit_opencode.deployer import OpenCodeDeployer
7
8
 
8
9
 
9
10
  def test_opencode_uses_native_current_session_contract_without_portable_methods(tmp_path):
@@ -1 +0,0 @@
1
- pactkit<2.26.0,>=2.25.2