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.
- {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/PKG-INFO +2 -2
- {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/pyproject.toml +2 -2
- {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/src/pactkit_opencode/deployer.py +156 -82
- {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/src/pactkit_opencode.egg-info/PKG-INFO +2 -2
- {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/src/pactkit_opencode.egg-info/SOURCES.txt +3 -0
- pactkit_opencode-2.27.0/src/pactkit_opencode.egg-info/requires.txt +1 -0
- pactkit_opencode-2.27.0/tests/test_a1_config_recovery.py +224 -0
- pactkit_opencode-2.27.0/tests/test_a2_mcp_retired.py +69 -0
- {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/tests/test_bug035_opencode_dual_layer.py +2 -25
- {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/tests/test_deploy_opencode_parity.py +32 -48
- pactkit_opencode-2.27.0/tests/test_host_body_snapshot.py +126 -0
- {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/tests/test_story069_opencode_format.py +3 -32
- {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/tests/test_story071_opencode_config_parity.py +3 -74
- {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/tests/test_story073_command_model_routing.py +2 -1
- {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/tests/test_story_work_units.py +2 -1
- pactkit_opencode-2.25.2/src/pactkit_opencode.egg-info/requires.txt +0 -1
- {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/README.md +0 -0
- {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/setup.cfg +0 -0
- {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/src/pactkit_opencode/__init__.py +0 -0
- {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/src/pactkit_opencode.egg-info/dependency_links.txt +0 -0
- {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/src/pactkit_opencode.egg-info/entry_points.txt +0 -0
- {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/src/pactkit_opencode.egg-info/top_level.txt +0 -0
- {pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/tests/test_story070_opencode_compliance.py +0 -0
|
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "pactkit-opencode"
|
|
7
|
-
version = "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.
|
|
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
|
-
-
|
|
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
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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(
|
|
278
|
-
except (json.JSONDecodeError,
|
|
279
|
-
|
|
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
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
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
|
|
296
|
-
|
|
297
|
-
config["instructions"] =
|
|
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`",
|
|
444
|
+
("Run `pactkit clean`",
|
|
445
|
+
"Run language-specific cleanup (e.g., `find . -name '__pycache__' -exec rm -rf {} +`)"),
|
|
372
446
|
]
|
|
373
447
|
|
|
374
448
|
|
{pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/src/pactkit_opencode.egg-info/SOURCES.txt
RENAMED
|
@@ -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
|
{pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/tests/test_bug035_opencode_dual_layer.py
RENAMED
|
@@ -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
|
-
|
|
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, "
|
|
58
|
-
with patch.object(OpenCodeDeployer, "
|
|
59
|
-
|
|
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, "
|
|
77
|
-
with patch.object(OpenCodeDeployer, "
|
|
78
|
-
with patch.
|
|
79
|
-
|
|
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, "
|
|
120
|
-
with patch.object(OpenCodeDeployer, "
|
|
121
|
-
with patch.
|
|
122
|
-
|
|
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, "
|
|
133
|
-
with patch.object(OpenCodeDeployer, "
|
|
134
|
-
with patch.
|
|
135
|
-
|
|
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, "
|
|
153
|
-
with patch.object(OpenCodeDeployer, "
|
|
154
|
-
with patch.
|
|
155
|
-
|
|
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, "
|
|
202
|
-
with patch.object(OpenCodeDeployer, "
|
|
203
|
-
with patch.
|
|
204
|
-
|
|
205
|
-
|
|
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, "
|
|
213
|
-
with patch.object(OpenCodeDeployer, "
|
|
214
|
-
with patch
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
234
|
-
|
|
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."""
|
{pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/tests/test_story071_opencode_config_parity.py
RENAMED
|
@@ -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
|
-
|
|
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
|
-
|
|
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)."""
|
{pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/tests/test_story073_command_model_routing.py
RENAMED
|
@@ -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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/src/pactkit_opencode.egg-info/entry_points.txt
RENAMED
|
File without changes
|
{pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/src/pactkit_opencode.egg-info/top_level.txt
RENAMED
|
File without changes
|
{pactkit_opencode-2.25.2 → pactkit_opencode-2.27.0}/tests/test_story070_opencode_compliance.py
RENAMED
|
File without changes
|