226 lines
9.1 KiB
Python
226 lines
9.1 KiB
Python
#!/usr/bin/env python3
|
|
"""Create a disposable-base validation plan for a staged 1C extension copy."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from check_1c_extension_staging import check_staging
|
|
from check_1c_extension_runner_config import check_config
|
|
|
|
|
|
def load_json(path: Path) -> dict[str, Any]:
|
|
return json.loads(path.read_text(encoding="utf-8-sig"))
|
|
|
|
|
|
def write_json(path: Path, data: dict[str, Any]) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
path.write_text(json.dumps(data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
|
|
|
|
def changed_objects(manifest: dict[str, Any]) -> list[dict[str, Any]]:
|
|
result = []
|
|
seen: set[tuple[str, str, str | None]] = set()
|
|
for item in manifest.get("files") or []:
|
|
parts = str(item.get("relative_path") or "").replace("\\", "/").split("/")
|
|
if len(parts) < 2:
|
|
continue
|
|
object_kind_folder, object_name = parts[0], parts[1]
|
|
key = (object_kind_folder, object_name, item.get("name"))
|
|
if key in seen:
|
|
continue
|
|
seen.add(key)
|
|
result.append(
|
|
{
|
|
"object_kind_folder": object_kind_folder,
|
|
"object_name": object_name,
|
|
"artifact_kind": item.get("kind"),
|
|
"artifact_name": item.get("name"),
|
|
"relative_path": item.get("relative_path"),
|
|
}
|
|
)
|
|
return result
|
|
|
|
|
|
def build_required_checks(manifest: dict[str, Any]) -> list[dict[str, Any]]:
|
|
objects = changed_objects(manifest)
|
|
checks = [
|
|
{
|
|
"id": "staging_integrity",
|
|
"kind": "adapter",
|
|
"required": True,
|
|
"description": "Run check_1c_extension_staging and require passed=true.",
|
|
"expected_evidence": ["staging-check.json"],
|
|
},
|
|
{
|
|
"id": "load_extension_into_disposable_base",
|
|
"kind": "1c_designer",
|
|
"required": True,
|
|
"description": "Load the staged extension source into a disposable 1C base. Production bases are forbidden.",
|
|
"expected_evidence": ["designer-load-log.txt", "platform-version.txt"],
|
|
},
|
|
{
|
|
"id": "configuration_syntax_check",
|
|
"kind": "1c_designer",
|
|
"required": True,
|
|
"description": "Run 1C Designer syntax/configuration validation after loading the staged extension.",
|
|
"expected_evidence": ["designer-syntax-check-log.txt"],
|
|
},
|
|
{
|
|
"id": "extension_save_or_package_check",
|
|
"kind": "1c_designer",
|
|
"required": True,
|
|
"description": "Save or package the extension from the disposable base and record that the platform accepted it.",
|
|
"expected_evidence": ["extension-save-log.txt"],
|
|
},
|
|
]
|
|
if objects:
|
|
checks.append(
|
|
{
|
|
"id": "changed_objects_smoke",
|
|
"kind": "1c_enterprise_or_manual",
|
|
"required": True,
|
|
"description": "Open or execute smoke scenarios for changed objects/forms/modules.",
|
|
"objects": objects,
|
|
"expected_evidence": ["changed-objects-smoke.json", "screenshots-or-manual-confirmation.md"],
|
|
}
|
|
)
|
|
checks.append(
|
|
{
|
|
"id": "rollback_evidence",
|
|
"kind": "operator",
|
|
"required": True,
|
|
"description": "Confirm the disposable base can be discarded or restored and no production state was modified.",
|
|
"expected_evidence": ["rollback-confirmation.md"],
|
|
}
|
|
)
|
|
return checks
|
|
|
|
|
|
def build_plan(staging_dir: Path, runner_config_path: Path | None = None) -> dict[str, Any]:
|
|
staging_check = check_staging(staging_dir)
|
|
runner_config_check = check_config(runner_config_path) if runner_config_path else None
|
|
if not staging_check.get("passed"):
|
|
status = "blocked"
|
|
runner_config = None
|
|
elif runner_config_check and not runner_config_check.get("passed"):
|
|
status = "blocked"
|
|
runner_config = runner_config_check.get("sanitized_config")
|
|
else:
|
|
runner_config = runner_config_check.get("sanitized_config") if runner_config_check else None
|
|
status = "ready_for_disposable_validation" if runner_config else "needs_runner_config"
|
|
|
|
manifest_path = staging_dir / "_codex_staging_manifest.json"
|
|
manifest = load_json(manifest_path) if manifest_path.exists() else {}
|
|
checks = build_required_checks(manifest)
|
|
evidence_root = staging_dir / "_codex_validation_evidence"
|
|
return {
|
|
"schema": "onec_extension_validation_plan.v1",
|
|
"created_at_utc": datetime.now(timezone.utc).isoformat(),
|
|
"status": status,
|
|
"passed": False,
|
|
"staging_dir": str(staging_dir),
|
|
"staging_manifest": str(manifest_path),
|
|
"bundle_dir": manifest.get("bundle_dir"),
|
|
"preferred_extension": manifest.get("preferred_extension"),
|
|
"task": manifest.get("task"),
|
|
"runner_config": runner_config,
|
|
"runner_config_required": runner_config is None,
|
|
"safety": {
|
|
"production_base_allowed": False,
|
|
"sql_write_allowed": False,
|
|
"source_extension_write_allowed": False,
|
|
"disposable_base_required": True,
|
|
},
|
|
"gates": [
|
|
{
|
|
"name": "staging_check",
|
|
"schema": staging_check.get("schema"),
|
|
"passed": staging_check.get("passed"),
|
|
"counts": staging_check.get("counts"),
|
|
},
|
|
{
|
|
"name": "runner_config_check",
|
|
"schema": (runner_config_check or {}).get("schema"),
|
|
"passed": (runner_config_check or {}).get("passed") if runner_config_check else None,
|
|
"counts": (runner_config_check or {}).get("counts") if runner_config_check else None,
|
|
},
|
|
],
|
|
"checks": checks,
|
|
"evidence": {
|
|
"root": str(evidence_root),
|
|
"required_files": sorted({file_name for check in checks for file_name in check.get("expected_evidence", [])}),
|
|
},
|
|
"operator_steps": [
|
|
"Use only a disposable 1C base copied from the target base or created for validation.",
|
|
"Run the adapter staging integrity check and save its JSON evidence.",
|
|
"Load the staged extension into the disposable base using the configured 1C runner or Designer procedure.",
|
|
"Run syntax/configuration validation in 1C tooling and save logs.",
|
|
"Run smoke checks for every changed object listed in this plan.",
|
|
"Discard or restore the disposable base after validation.",
|
|
],
|
|
"details": {
|
|
"staging_check": staging_check,
|
|
"runner_config_check": runner_config_check,
|
|
"changed_objects": changed_objects(manifest),
|
|
},
|
|
}
|
|
|
|
|
|
def render_markdown(plan: dict[str, Any]) -> str:
|
|
lines = [
|
|
"# 1C Extension Disposable Validation Plan",
|
|
"",
|
|
f"Status: `{plan.get('status')}`",
|
|
f"Staging: `{plan.get('staging_dir')}`",
|
|
f"Bundle: `{plan.get('bundle_dir')}`",
|
|
f"Preferred extension: `{plan.get('preferred_extension')}`",
|
|
"",
|
|
"## Safety",
|
|
"",
|
|
"- Production base is not allowed.",
|
|
"- SQL writes are not allowed.",
|
|
"- Source extension writes are not allowed.",
|
|
"- Disposable base validation is required.",
|
|
"",
|
|
"## Required Checks",
|
|
"",
|
|
]
|
|
for check in plan.get("checks") or []:
|
|
lines.append(f"- `{check.get('id')}` ({check.get('kind')}): {check.get('description')}")
|
|
changed = (plan.get("details") or {}).get("changed_objects") or []
|
|
if changed:
|
|
lines.extend(["", "## Changed Objects", ""])
|
|
for item in changed:
|
|
lines.append(f"- `{item.get('object_kind_folder')}/{item.get('object_name')}`: `{item.get('artifact_name')}` ({item.get('artifact_kind')})")
|
|
lines.extend(["", "## Evidence", ""])
|
|
for name in (plan.get("evidence") or {}).get("required_files") or []:
|
|
lines.append(f"- `{name}`")
|
|
return "\n".join(lines) + "\n"
|
|
|
|
|
|
def main() -> int:
|
|
parser = argparse.ArgumentParser(description="Create a disposable-base validation plan for a staged 1C extension copy.")
|
|
parser.add_argument("--staging-dir", type=Path, required=True)
|
|
parser.add_argument("--runner-config", type=Path)
|
|
parser.add_argument("--output", type=Path)
|
|
parser.add_argument("--markdown-output", type=Path)
|
|
args = parser.parse_args()
|
|
|
|
result = build_plan(args.staging_dir, args.runner_config)
|
|
if args.output:
|
|
write_json(args.output, result)
|
|
if args.markdown_output:
|
|
args.markdown_output.parent.mkdir(parents=True, exist_ok=True)
|
|
args.markdown_output.write_text(render_markdown(result), encoding="utf-8")
|
|
print(json.dumps({"output": str(args.output) if args.output else None, "status": result["status"], "checks": len(result["checks"])}, ensure_ascii=False))
|
|
return 0 if result["status"] != "blocked" else 2
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|