diff --git a/docs/runbooks/adapter-1c-mcp.md b/docs/runbooks/adapter-1c-mcp.md index 9e95344..86b6454 100644 --- a/docs/runbooks/adapter-1c-mcp.md +++ b/docs/runbooks/adapter-1c-mcp.md @@ -776,7 +776,12 @@ Use `-SavedStateTable ConfigSave` or `-SavedStateTable ConfigCASSave` to choose which save-layer table is used by the copy plan and saved-state write smokes. Add `-RequireSelectorChainWritePlanComposition` when the selected base/object must have a saved-state stream that lets the selector-chain smoke compose a -concrete read-only `metadata.write.plan`. +concrete read-only `metadata.write.plan`. The same strict mode also requires +the write-preflight smoke to discover an extension form by public +`extension/ref/form/member` names, compose an allowed plan, and prove that the +repository and support gates use the same resolved `extension:` layer. +Run that check directly with +`scripts/smoke_1c_write_preflight.py --require-name-first-extension-form`. To exercise the MCP proxy itself, switch transport and URL: diff --git a/scripts/check_1c_verify_reports.py b/scripts/check_1c_verify_reports.py index 5f3abd5..6ccc517 100644 --- a/scripts/check_1c_verify_reports.py +++ b/scripts/check_1c_verify_reports.py @@ -4,6 +4,7 @@ from __future__ import annotations import argparse import json import os +import re import sys import tempfile import time @@ -15,6 +16,10 @@ ROOT = Path(__file__).resolve().parents[1] DEFAULT_REPORTS_ROOT = ROOT / "reports" / "1c-sql" SAVED_STATE_TABLES = {"ConfigSave", "ConfigCASSave"} SAVED_STATE_SOURCE_BY_TARGET = {"ConfigSave": "Config", "ConfigCASSave": "ConfigCAS"} +EXTENSION_GUID_LAYER_RE = re.compile( + r"^extension:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) def safe_path_segment(value: str) -> str: @@ -396,7 +401,7 @@ def validate_write_preflight( failures.append({"code": "write_preflight_not_ok", "label": label, "path": str(path), "status": report.get("status")}) if report.get("failures"): failures.append({"code": "write_preflight_failures_present", "label": label, "path": str(path), "failures": report.get("failures")}) - for check in ("method_exposed", "effective_path_preflight", "concrete_saved_state_preflight"): + for check in ("method_exposed", "effective_path_preflight", "concrete_saved_state_preflight", "name_first_extension_form_preflight"): if check not in checks: failures.append({"code": "write_preflight_check_missing", "label": label, "check": check, "path": str(path)}) expect_check(checks, failures, label, path, "method_exposed", {"status": "ok"}, failure_code="write_preflight_check_field_unexpected") @@ -420,6 +425,51 @@ def validate_write_preflight( {"schema": "onec_metadata_write_preflight.v1", "writer": "metadata.module.write_apply"}, failure_code="write_preflight_check_field_unexpected", ) + name_first_form = checks.get("name_first_extension_form_preflight") if isinstance(checks.get("name_first_extension_form_preflight"), dict) else {} + require_name_first_form = bool( + (report.get("requirements") or {}).get("name_first_extension_form") + if isinstance(report.get("requirements"), dict) + else False + ) + if name_first_form.get("status") == "skipped_no_public_extension_form_target": + if require_name_first_form: + failures.append({ + "code": "write_preflight_name_first_extension_form_required", + "label": label, + "path": str(path), + }) + else: + expect_check( + checks, + failures, + label, + path, + "name_first_extension_form_preflight", + { + "schema": "onec_metadata_write_preflight.v1", + "plan_status": "planned", + "plan_allowed": True, + "name_first": True, + }, + failure_code="write_preflight_check_field_unexpected", + ) + repository_layer = str(name_first_form.get("repository_layer_id") or "") + support_layer = str(name_first_form.get("support_layer_id") or "") + if not EXTENSION_GUID_LAYER_RE.fullmatch(repository_layer): + failures.append({ + "code": "write_preflight_extension_layer_unresolved", + "label": label, + "path": str(path), + "repository_layer_id": repository_layer or None, + }) + if repository_layer != support_layer: + failures.append({ + "code": "write_preflight_extension_layer_mismatch", + "label": label, + "path": str(path), + "repository_layer_id": repository_layer or None, + "support_layer_id": support_layer or None, + }) if require_mcp_initialize: if "mcp.initialize" not in checks: failures.append({"code": "write_preflight_check_missing", "label": label, "check": "mcp.initialize", "path": str(path)}) @@ -1572,6 +1622,17 @@ def write_self_test_reports(report_dir: Path, *, base_id: str, composed: bool, s "freshness": "live_sql_verified", "writer": "metadata.module.write_apply", }, + "name_first_extension_form_preflight": { + "schema": "onec_metadata_write_preflight.v1", + "status": "ready", + "allowed": True, + "plan_status": "planned", + "plan_allowed": True, + "repository_layer_id": "extension:11111111-1111-1111-1111-111111111111", + "support_layer_id": "extension:11111111-1111-1111-1111-111111111111", + "name_first": True, + "extension": "test2", + }, }, "failures": [], } diff --git a/scripts/smoke_1c_write_preflight.py b/scripts/smoke_1c_write_preflight.py index aec4bdd..bbc1f98 100644 --- a/scripts/smoke_1c_write_preflight.py +++ b/scripts/smoke_1c_write_preflight.py @@ -3,6 +3,7 @@ from __future__ import annotations import argparse import json +import re import urllib.request from pathlib import Path from typing import Any @@ -23,6 +24,10 @@ PREFLIGHT_CLASSIFICATION_STATUSES = { "blocked_by_support_rule", "blocked_support_unknown", } +EXTENSION_GUID_LAYER_RE = re.compile( + r"^extension:[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + re.IGNORECASE, +) def post_json(url: str, payload: dict[str, Any], *, timeout: float, headers: dict[str, str] | None = None) -> tuple[dict[str, str], dict[str, Any]]: @@ -155,7 +160,61 @@ def first_saved_module_target(endpoint_url: str, base_id: str, timeout: float, * ) -def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str) -> dict[str, Any]: +def first_public_extension_form_candidate(result: dict[str, Any]) -> dict[str, Any] | None: + candidates: list[dict[str, Any]] = [] + for form in result.get("forms") or []: + if not isinstance(form, dict): + continue + for match in form.get("matches") or []: + if not isinstance(match, dict): + continue + selector = match.get("selector") if isinstance(match.get("selector"), dict) else {} + if not selector.get("extension") or not selector.get("ref") or not selector.get("form"): + continue + if not any(selector.get(key) for key in ("command", "element", "attribute")): + continue + if any(key in selector for key in ("table", "file_name", "guid", "form_guid", "module_ref")): + continue + for prop in match.get("writable_properties") or []: + if not isinstance(prop, dict) or prop.get("value_type") != "string": + continue + property_name = str(prop.get("presentation") or prop.get("property") or "").strip() + if not property_name: + continue + normalized_property = str(prop.get("property") or "").strip().casefold() + candidates.append( + { + "priority": 0 if normalized_property == "title" else (2 if normalized_property in {"id", "name"} else 1), + "target": { + "kind": "form", + **{ + key: value + for key, value in selector.items() + if key in {"extension", "ref", "form", "command", "element", "attribute"} + and value not in (None, "") + }, + }, + "edit": { + "property": property_name, + "value": f"{str(prop.get('value') or '')} [NAME-FIRST PREFLIGHT]", + }, + } + ) + if not candidates: + return None + candidate = sorted(candidates, key=lambda item: int(item.get("priority") or 0))[0] + candidate.pop("priority", None) + return candidate + + +def run_smoke( + endpoint_url: str, + base_id: str, + timeout: float, + *, + transport: str, + require_name_first_extension_form: bool = False, +) -> dict[str, Any]: failures: list[str] = [] checks: dict[str, Any] = {} session_id: str | None = None @@ -233,12 +292,79 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str else: checks["concrete_saved_state_preflight"] = {"status": "skipped_no_saved_module_target"} + forms = rpc_call( + endpoint_url, + "metadata.saved_state.forms.search", + { + "base_id": base_id, + "limit": 20, + "scan_limit": 1000, + "timeout_seconds": int(timeout), + }, + timeout, + transport=transport, + session_id=session_id, + ) + form_candidate = first_public_extension_form_candidate(forms) + if not form_candidate: + checks["name_first_extension_form_preflight"] = { + "status": "skipped_no_public_extension_form_target", + "search_status": forms.get("status"), + "forms": int((forms.get("counts") or {}).get("forms") or 0), + } + if require_name_first_extension_form: + failures.append("name-first extension form preflight target is required but was not discovered") + else: + form_preflight = rpc_call( + endpoint_url, + "metadata.write.preflight", + { + "base_id": base_id, + "target": form_candidate["target"], + "edits": [form_candidate["edit"]], + "resolve_origin": False, + "timeout_seconds": int(timeout), + }, + timeout, + transport=transport, + session_id=session_id, + ) + plan = form_preflight.get("plan") if isinstance(form_preflight.get("plan"), dict) else {} + repository = form_preflight.get("repository") if isinstance(form_preflight.get("repository"), dict) else {} + support = form_preflight.get("support") if isinstance(form_preflight.get("support"), dict) else {} + repository_layer = str(repository.get("layer_id") or "") + support_layer = str(support.get("layer_id") or "") + name_first = not any( + key in form_candidate["target"] + for key in ("table", "file_name", "guid", "form_guid", "module_ref", "extension_guid") + ) + checks["name_first_extension_form_preflight"] = { + "schema": form_preflight.get("schema"), + "status": form_preflight.get("status"), + "allowed": form_preflight.get("allowed"), + "plan_status": plan.get("status"), + "plan_allowed": plan.get("allowed"), + "repository_layer_id": repository_layer or None, + "support_layer_id": support_layer or None, + "name_first": name_first, + "extension": form_candidate["target"].get("extension"), + } + require(form_preflight.get("schema") == "onec_metadata_write_preflight.v1", "name-first extension form preflight must return expected schema", failures) + require(classified_preflight_status(form_preflight.get("status")), "name-first extension form preflight must classify readiness or a safety gate", failures) + require(plan.get("status") == "planned" and plan.get("allowed") is True, "name-first extension form preflight plan must be allowed and planned", failures) + require(name_first, "extension form preflight input must remain name-first", failures) + require(bool(EXTENSION_GUID_LAYER_RE.fullmatch(repository_layer)), "repository gate must use a resolved extension GUID layer", failures) + require(repository_layer == support_layer, "repository and support gates must use the same extension layer", failures) + return { "schema": "onec_write_preflight_smoke.v1", "status": "ok" if not failures else "failed", "endpoint_url": endpoint_url, "transport": transport, "base_id": base_id, + "requirements": { + "name_first_extension_form": require_name_first_extension_form, + }, "checks": checks, "failures": failures, **({"mcp_initialize": initialize_result} if initialize_result and failures else {}), @@ -253,11 +379,18 @@ def main() -> int: parser.add_argument("--base-id", default="upo_test") parser.add_argument("--timeout", type=float, default=30.0) parser.add_argument("--report", type=Path) + parser.add_argument("--require-name-first-extension-form", action="store_true") args = parser.parse_args() endpoint_url = args.mcp_url if args.transport == "mcp" else args.base_url try: - report = run_smoke(endpoint_url, args.base_id, args.timeout, transport=args.transport) + report = run_smoke( + endpoint_url, + args.base_id, + args.timeout, + transport=args.transport, + require_name_first_extension_form=args.require_name_first_extension_form, + ) except (TimeoutError, URLError, OSError) as exc: report = { "schema": "onec_write_preflight_smoke.v1", diff --git a/scripts/verify_1c_adapter_deployment.ps1 b/scripts/verify_1c_adapter_deployment.ps1 index e6525d9..471b7a0 100644 --- a/scripts/verify_1c_adapter_deployment.ps1 +++ b/scripts/verify_1c_adapter_deployment.ps1 @@ -206,7 +206,7 @@ function Assert-WritePreflightReport { if ($report.failures -and $report.failures.Count -gt 0) { throw "$Label report contains failures: $Path" } - foreach ($check in @("method_exposed", "effective_path_preflight", "concrete_saved_state_preflight")) { + foreach ($check in @("method_exposed", "effective_path_preflight", "concrete_saved_state_preflight", "name_first_extension_form_preflight")) { if ($report.checks.PSObject.Properties.Name -notcontains $check) { throw "$Label report is missing check '$check': $Path" } @@ -486,6 +486,9 @@ try { "--report", $writePreflightReport ) + if ($RequireSelectorChainWritePlanComposition) { + $writePreflightCommand += "--require-name-first-extension-form" + } Invoke-CheckedCommand -Label "REST adapter write-preflight smoke ($currentBaseId)" -Command $writePreflightCommand Assert-WritePreflightReport -Label "REST adapter write-preflight smoke ($currentBaseId)" -Path $writePreflightReport } @@ -782,6 +785,9 @@ try { "--report", $mcpWritePreflightReport ) + if ($RequireSelectorChainWritePlanComposition) { + $mcpWritePreflightCommand += "--require-name-first-extension-form" + } Invoke-CheckedCommand -Label "MCP proxy write-preflight smoke ($currentBaseId)" -Command $mcpWritePreflightCommand Assert-WritePreflightReport -Label "MCP proxy write-preflight smoke ($currentBaseId)" -Path $mcpWritePreflightReport } diff --git a/tests/1c/test_write_preflight_smoke.py b/tests/1c/test_write_preflight_smoke.py index 3408b5c..1c8f27e 100644 --- a/tests/1c/test_write_preflight_smoke.py +++ b/tests/1c/test_write_preflight_smoke.py @@ -61,3 +61,126 @@ def test_saved_module_target_prefers_bounded_stream(monkeypatch) -> None: ) assert result == bounded_stream + + +def test_public_extension_form_candidate_is_name_first_and_prefers_title() -> None: + result = smoke.first_public_extension_form_candidate( + { + "forms": [ + { + "matches": [ + { + "selector": { + "extension": "test2", + "ref": "Catalog.test2", + "form": "t_Форма", + "command": "ЗаменаДомена", + }, + "writable_properties": [ + { + "property": "name", + "presentation": "Имя", + "value": "ЗаменаДомена", + "value_type": "string", + }, + { + "property": "title", + "presentation": "Заголовок", + "value": "Замена домена", + "value_type": "string", + }, + ], + } + ], + } + ] + } + ) + + assert result == { + "target": { + "kind": "form", + "extension": "test2", + "ref": "Catalog.test2", + "form": "t_Форма", + "command": "ЗаменаДомена", + }, + "edit": { + "property": "Заголовок", + "value": "Замена домена [NAME-FIRST PREFLIGHT]", + }, + } + + +def test_name_first_extension_form_preflight_rejects_base_gate_layer(monkeypatch) -> None: + def fake_rpc_call( + _endpoint_url: str, + method: str, + payload: dict[str, Any], + _timeout: float, + *, + transport: str, + session_id: str | None, + ) -> dict[str, Any]: + assert transport == "rest" + assert session_id is None + if method == "help.methods": + return {"methods": [{"name": "metadata.write.preflight"}]} + if method == "metadata.saved_state.modules.search": + return {"status": "ok", "modules": []} + if method == "metadata.saved_state.forms.search": + return { + "status": "ok", + "counts": {"forms": 1}, + "forms": [ + { + "matches": [ + { + "selector": { + "extension": "test2", + "ref": "Catalog.test2", + "form": "t_Форма", + "command": "ЗаменаДомена", + }, + "writable_properties": [ + { + "property": "title", + "presentation": "Заголовок", + "value": "Замена домена", + "value_type": "string", + } + ], + } + ] + } + ], + } + if method == "metadata.write.preflight" and (payload.get("target") or {}).get("extension"): + return { + "schema": "onec_metadata_write_preflight.v1", + "status": "ready", + "allowed": True, + "plan": {"status": "planned", "allowed": True}, + "repository": {"layer_id": "base"}, + "support": {"layer_id": "base"}, + } + return { + "schema": "onec_metadata_write_preflight.v1", + "status": "blocked", + "allowed": False, + "plan": {"allowed": False}, + } + + monkeypatch.setattr(smoke, "rpc_call", fake_rpc_call) + + report = smoke.run_smoke( + "http://adapter", + "upo_test", + 30, + transport="rest", + require_name_first_extension_form=True, + ) + + assert report["status"] == "failed" + assert report["checks"]["name_first_extension_form_preflight"]["name_first"] is True + assert any("resolved extension GUID layer" in failure for failure in report["failures"])