From cbbcddc6722f32411d36a8b3ac143402f324a392 Mon Sep 17 00:00:00 2001 From: Chojan Shang Date: Fri, 9 Oct 2026 17:10:54 +0800 Subject: [PATCH] fix(dify): decouple generated contract from whole OpenAPI hash Refs #1904. Preserve complete artifact drift checks and cover unrelated OpenAPI changes and consumed schema changes. --- integrations/dify/generate_contract.py | 6 +- .../plugin/powercontext_dify/contract.json | 1 - .../dify/tests/test_contract_generation.py | 112 ++++++++++++++++++ 3 files changed, 113 insertions(+), 6 deletions(-) create mode 100644 integrations/dify/tests/test_contract_generation.py diff --git a/integrations/dify/generate_contract.py b/integrations/dify/generate_contract.py index 820ba6c5b..90ead68c0 100644 --- a/integrations/dify/generate_contract.py +++ b/integrations/dify/generate_contract.py @@ -17,7 +17,6 @@ from __future__ import annotations import argparse -import hashlib import json from copy import deepcopy from pathlib import Path @@ -131,9 +130,7 @@ def workflow_schema(schema, reference): def build(): - # Canonical LF text makes generated contracts identical across Git's Windows checkouts. - raw = (ROOT / "openapi/powercontext.yaml").read_text(encoding="utf-8").encode("utf-8") - spec = yaml.safe_load(raw) + spec = yaml.safe_load((ROOT / "openapi/powercontext.yaml").read_text(encoding="utf-8")) schemas = spec["components"]["schemas"] selected = {value[0] for value in TOOLS.values()} | {"resolve_scope_binding", "get_scope"} operations = {} @@ -183,7 +180,6 @@ def inline(value): visit(list(responses.values())) visit({"$ref": "#/components/schemas/ErrorResponse"}) contract = { - "openapi_sha256": hashlib.sha256(raw).hexdigest(), "api_version": spec["info"]["version"], "operations": operations, "tools": {name: values[0] for name, values in TOOLS.items()}, diff --git a/integrations/dify/plugin/powercontext_dify/contract.json b/integrations/dify/plugin/powercontext_dify/contract.json index b3168b4d9..8b732152c 100644 --- a/integrations/dify/plugin/powercontext_dify/contract.json +++ b/integrations/dify/plugin/powercontext_dify/contract.json @@ -1,5 +1,4 @@ { - "openapi_sha256": "9f4ba7b35ecd3b3639af4e648aff84b713a7f30b51d5a4c30c8809aeb8dc8024", "api_version": "1.2.0", "operations": { "get_scope": { diff --git a/integrations/dify/tests/test_contract_generation.py b/integrations/dify/tests/test_contract_generation.py new file mode 100644 index 000000000..411f26208 --- /dev/null +++ b/integrations/dify/tests/test_contract_generation.py @@ -0,0 +1,112 @@ +# Copyright (c) 2026 OceanBase. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Selected contract generation must ignore unrelated OpenAPI changes and detect drift.""" + +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +from pathlib import Path + +import pytest +import yaml + + +def generate(repository: Path, *, check: bool = False) -> subprocess.CompletedProcess[str]: + command = [sys.executable, str(repository / "integrations/dify/generate_contract.py")] + if check: + command.append("--check") + return subprocess.run(command, capture_output=True, text=True, timeout=30, check=False) + + +@pytest.fixture +def repository(tmp_path: Path) -> Path: + project = Path(__file__).resolve().parents[1] + for relative in ("generate_contract.py", "catalog.py", "plugin/powercontext_dify/policy.py"): + destination = tmp_path / "integrations/dify" / relative + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(project / relative, destination) + (tmp_path / "openapi").mkdir() + shutil.copyfile(project.parents[1] / "openapi/powercontext.yaml", tmp_path / "openapi/powercontext.yaml") + result = generate(tmp_path) + assert result.returncode == 0, result.stderr + return tmp_path + + +def generated_files(repository: Path) -> dict[str, bytes]: + plugin = repository / "integrations/dify/plugin" + paths = [plugin / "powercontext_dify/contract.json", *sorted((plugin / "tools").iterdir())] + return {str(path.relative_to(plugin)): path.read_bytes() for path in paths} + + +@pytest.mark.parametrize("change", ["comment", "operation", "schema"]) +def test_unrelated_openapi_changes_leave_all_dify_outputs_unchanged(repository: Path, change: str) -> None: + before = generated_files(repository) + contract = repository / "openapi/powercontext.yaml" + if change == "comment": + contract.write_text( + contract.read_text(encoding="utf-8") + "\n# Unrelated documentation edit.\n", encoding="utf-8" + ) + else: + spec = yaml.safe_load(contract.read_text(encoding="utf-8")) + if change == "operation": + spec["paths"]["/v1/unrelated-generator-regression"] = { + "get": {"operationId": "unrelated_generator_regression", "responses": {"204": {"description": "Empty"}}} + } + else: + spec["components"]["schemas"]["UnrelatedGeneratorRegression"] = {"type": "string", "maxLength": 42} + contract.write_text(yaml.safe_dump(spec, allow_unicode=True, sort_keys=False), encoding="utf-8") + checked = generate(repository, check=True) + assert checked.returncode == 0, checked.stderr + regenerated = generate(repository) + assert regenerated.returncode == 0, regenerated.stderr + assert generated_files(repository) == before + + +def test_consumed_schema_changes_fail_stale_checks_and_update_outputs(repository: Path) -> None: + before = generated_files(repository) + contract = repository / "openapi/powercontext.yaml" + spec = yaml.safe_load(contract.read_text(encoding="utf-8")) + spec["components"]["schemas"]["SearchMemoryHit"]["properties"]["text"]["description"] = "Updated retained evidence." + contract.write_text(yaml.safe_dump(spec, allow_unicode=True, sort_keys=False), encoding="utf-8") + + stale = generate(repository, check=True) + assert stale.returncode != 0 + assert "contract/declarations require regeneration" in stale.stderr + assert generated_files(repository) == before + + regenerated = generate(repository) + assert regenerated.returncode == 0, regenerated.stderr + after = generated_files(repository) + assert after["powercontext_dify/contract.json"] != before["powercontext_dify/contract.json"] + assert after["tools/pc_search.yaml"] != before["tools/pc_search.yaml"] + spec = json.loads(after["powercontext_dify/contract.json"]) + assert ( + spec["components"]["schemas"]["SearchMemoryHit"]["properties"]["text"]["description"] + == "Updated retained evidence." + ) + checked = generate(repository, check=True) + assert checked.returncode == 0, checked.stderr + + +@pytest.mark.parametrize("artifact", ["powercontext_dify/contract.json", "tools/pc_search.yaml", "tools/pc_search.py"]) +def test_check_rejects_drift_in_contract_and_tool_declarations(repository: Path, artifact: str) -> None: + path = repository / "integrations/dify/plugin" / artifact + path.write_text(path.read_text(encoding="utf-8") + "\n", encoding="utf-8") + stale = generate(repository, check=True) + assert stale.returncode != 0 + assert artifact in stale.stderr