Require name-first extension preflight smoke

This commit is contained in:
2026-07-26 17:48:04 +03:00
parent ef2a94a3d0
commit d99d57eaf9
5 changed files with 333 additions and 5 deletions
+6 -1
View File
@@ -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. which save-layer table is used by the copy plan and saved-state write smokes.
Add `-RequireSelectorChainWritePlanComposition` when the selected base/object Add `-RequireSelectorChainWritePlanComposition` when the selected base/object
must have a saved-state stream that lets the selector-chain smoke compose a 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: To exercise the MCP proxy itself, switch transport and URL:
+62 -1
View File
@@ -4,6 +4,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import os import os
import re
import sys import sys
import tempfile import tempfile
import time import time
@@ -15,6 +16,10 @@ ROOT = Path(__file__).resolve().parents[1]
DEFAULT_REPORTS_ROOT = ROOT / "reports" / "1c-sql" DEFAULT_REPORTS_ROOT = ROOT / "reports" / "1c-sql"
SAVED_STATE_TABLES = {"ConfigSave", "ConfigCASSave"} SAVED_STATE_TABLES = {"ConfigSave", "ConfigCASSave"}
SAVED_STATE_SOURCE_BY_TARGET = {"ConfigSave": "Config", "ConfigCASSave": "ConfigCAS"} 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: 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")}) failures.append({"code": "write_preflight_not_ok", "label": label, "path": str(path), "status": report.get("status")})
if report.get("failures"): if report.get("failures"):
failures.append({"code": "write_preflight_failures_present", "label": label, "path": str(path), "failures": 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: if check not in checks:
failures.append({"code": "write_preflight_check_missing", "label": label, "check": check, "path": str(path)}) 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") 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"}, {"schema": "onec_metadata_write_preflight.v1", "writer": "metadata.module.write_apply"},
failure_code="write_preflight_check_field_unexpected", 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 require_mcp_initialize:
if "mcp.initialize" not in checks: if "mcp.initialize" not in checks:
failures.append({"code": "write_preflight_check_missing", "label": label, "check": "mcp.initialize", "path": str(path)}) 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", "freshness": "live_sql_verified",
"writer": "metadata.module.write_apply", "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": [], "failures": [],
} }
+135 -2
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import argparse import argparse
import json import json
import re
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
from typing import Any from typing import Any
@@ -23,6 +24,10 @@ PREFLIGHT_CLASSIFICATION_STATUSES = {
"blocked_by_support_rule", "blocked_by_support_rule",
"blocked_support_unknown", "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]]: 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] = [] failures: list[str] = []
checks: dict[str, Any] = {} checks: dict[str, Any] = {}
session_id: str | None = None session_id: str | None = None
@@ -233,12 +292,79 @@ def run_smoke(endpoint_url: str, base_id: str, timeout: float, *, transport: str
else: else:
checks["concrete_saved_state_preflight"] = {"status": "skipped_no_saved_module_target"} 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 { return {
"schema": "onec_write_preflight_smoke.v1", "schema": "onec_write_preflight_smoke.v1",
"status": "ok" if not failures else "failed", "status": "ok" if not failures else "failed",
"endpoint_url": endpoint_url, "endpoint_url": endpoint_url,
"transport": transport, "transport": transport,
"base_id": base_id, "base_id": base_id,
"requirements": {
"name_first_extension_form": require_name_first_extension_form,
},
"checks": checks, "checks": checks,
"failures": failures, "failures": failures,
**({"mcp_initialize": initialize_result} if initialize_result and failures else {}), **({"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("--base-id", default="upo_test")
parser.add_argument("--timeout", type=float, default=30.0) parser.add_argument("--timeout", type=float, default=30.0)
parser.add_argument("--report", type=Path) parser.add_argument("--report", type=Path)
parser.add_argument("--require-name-first-extension-form", action="store_true")
args = parser.parse_args() args = parser.parse_args()
endpoint_url = args.mcp_url if args.transport == "mcp" else args.base_url endpoint_url = args.mcp_url if args.transport == "mcp" else args.base_url
try: 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: except (TimeoutError, URLError, OSError) as exc:
report = { report = {
"schema": "onec_write_preflight_smoke.v1", "schema": "onec_write_preflight_smoke.v1",
+7 -1
View File
@@ -206,7 +206,7 @@ function Assert-WritePreflightReport {
if ($report.failures -and $report.failures.Count -gt 0) { if ($report.failures -and $report.failures.Count -gt 0) {
throw "$Label report contains failures: $Path" 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) { if ($report.checks.PSObject.Properties.Name -notcontains $check) {
throw "$Label report is missing check '$check': $Path" throw "$Label report is missing check '$check': $Path"
} }
@@ -486,6 +486,9 @@ try {
"--report", "--report",
$writePreflightReport $writePreflightReport
) )
if ($RequireSelectorChainWritePlanComposition) {
$writePreflightCommand += "--require-name-first-extension-form"
}
Invoke-CheckedCommand -Label "REST adapter write-preflight smoke ($currentBaseId)" -Command $writePreflightCommand Invoke-CheckedCommand -Label "REST adapter write-preflight smoke ($currentBaseId)" -Command $writePreflightCommand
Assert-WritePreflightReport -Label "REST adapter write-preflight smoke ($currentBaseId)" -Path $writePreflightReport Assert-WritePreflightReport -Label "REST adapter write-preflight smoke ($currentBaseId)" -Path $writePreflightReport
} }
@@ -782,6 +785,9 @@ try {
"--report", "--report",
$mcpWritePreflightReport $mcpWritePreflightReport
) )
if ($RequireSelectorChainWritePlanComposition) {
$mcpWritePreflightCommand += "--require-name-first-extension-form"
}
Invoke-CheckedCommand -Label "MCP proxy write-preflight smoke ($currentBaseId)" -Command $mcpWritePreflightCommand Invoke-CheckedCommand -Label "MCP proxy write-preflight smoke ($currentBaseId)" -Command $mcpWritePreflightCommand
Assert-WritePreflightReport -Label "MCP proxy write-preflight smoke ($currentBaseId)" -Path $mcpWritePreflightReport Assert-WritePreflightReport -Label "MCP proxy write-preflight smoke ($currentBaseId)" -Path $mcpWritePreflightReport
} }
+123
View File
@@ -61,3 +61,126 @@ def test_saved_module_target_prefers_bounded_stream(monkeypatch) -> None:
) )
assert result == bounded_stream 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"])