Require name-first extension preflight smoke
This commit is contained in:
@@ -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:<GUID>` 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:
|
||||
|
||||
|
||||
@@ -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": [],
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user