Initial SQL-only 1C adapter baseline
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Validate that a 1C change proposal stays within the read-only/extension-first safety contract."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
|
||||
FORBIDDEN_PATH_PARTS = {
|
||||
"config",
|
||||
"configsave",
|
||||
"configcas",
|
||||
}
|
||||
|
||||
|
||||
def load_json(path: Path) -> dict[str, Any]:
|
||||
return json.loads(path.read_text(encoding="utf-8-sig"))
|
||||
|
||||
|
||||
def issue(severity: str, code: str, message: str, *, target: dict[str, Any] | None = None) -> dict[str, Any]:
|
||||
result = {"severity": severity, "code": code, "message": message}
|
||||
if target:
|
||||
result["target"] = target
|
||||
return result
|
||||
|
||||
|
||||
def path_text(target: dict[str, Any]) -> str:
|
||||
return str(target.get("path") or target.get("module_path") or "")
|
||||
|
||||
|
||||
def normalized_path_parts(path: str) -> list[str]:
|
||||
return [part.casefold() for part in path.replace("/", "\\").split("\\") if part]
|
||||
|
||||
|
||||
def is_extension_origin(target: dict[str, Any], preferred_extension: str | None) -> bool:
|
||||
origin = target.get("origin") or {}
|
||||
if origin.get("layer") != "extension":
|
||||
return False
|
||||
if preferred_extension and origin.get("extension") != preferred_extension:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_extension_path(path: str, preferred_extension: str | None) -> bool:
|
||||
parts = normalized_path_parts(path)
|
||||
if "расширения" not in parts and "extensions" not in parts:
|
||||
return False
|
||||
if preferred_extension:
|
||||
lowered = preferred_extension.casefold()
|
||||
return lowered in parts
|
||||
return True
|
||||
|
||||
|
||||
def forbidden_path_reason(path: str) -> str | None:
|
||||
parts = normalized_path_parts(path)
|
||||
for part in parts:
|
||||
if part in FORBIDDEN_PATH_PARTS:
|
||||
return part
|
||||
if path.startswith("_") or "\\_" in path:
|
||||
return "sql_physical_name_like_path"
|
||||
return None
|
||||
|
||||
|
||||
def check_target_exists(target: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
findings = []
|
||||
path = path_text(target)
|
||||
if not path:
|
||||
findings.append(issue("error", "missing_path", "Target has no path.", target=target))
|
||||
return findings
|
||||
if not Path(path).exists():
|
||||
findings.append(issue("error", "path_not_found", f"Target path does not exist: {path}", target=target))
|
||||
line = target.get("line")
|
||||
if line:
|
||||
try:
|
||||
line_int = int(line)
|
||||
if line_int < 1:
|
||||
findings.append(issue("error", "invalid_line", f"Invalid target line: {line}", target=target))
|
||||
except (TypeError, ValueError):
|
||||
findings.append(issue("error", "invalid_line", f"Invalid target line: {line}", target=target))
|
||||
return findings
|
||||
|
||||
|
||||
def check_write_candidate(target: dict[str, Any], preferred_extension: str | None) -> list[dict[str, Any]]:
|
||||
findings = []
|
||||
path = path_text(target)
|
||||
findings.extend(check_target_exists(target))
|
||||
if not is_extension_origin(target, preferred_extension):
|
||||
findings.append(issue("error", "write_candidate_not_preferred_extension_origin", "Write candidate is not in the preferred extension origin.", target=target))
|
||||
if not is_extension_path(path, preferred_extension):
|
||||
findings.append(issue("error", "write_candidate_not_preferred_extension_path", "Write candidate path is not inside the preferred extension directory.", target=target))
|
||||
forbidden = forbidden_path_reason(path)
|
||||
if forbidden:
|
||||
findings.append(issue("error", "forbidden_write_path", f"Write candidate path is forbidden: {forbidden}", target=target))
|
||||
if target.get("kind") not in {"bsl_module", "form_xml"}:
|
||||
findings.append(issue("warning", "unusual_write_candidate_kind", f"Unexpected write candidate kind: {target.get('kind')}", target=target))
|
||||
return findings
|
||||
|
||||
|
||||
def check_reference_target(target: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
findings = check_target_exists(target)
|
||||
forbidden = forbidden_path_reason(path_text(target))
|
||||
if forbidden:
|
||||
findings.append(issue("warning", "forbidden_reference_path", f"Reference path is forbidden for writes and must remain read-only: {forbidden}", target=target))
|
||||
return findings
|
||||
|
||||
|
||||
def check_proposal(proposal: dict[str, Any]) -> dict[str, Any]:
|
||||
findings = []
|
||||
strategy = proposal.get("write_strategy") or {}
|
||||
preferred_extension = strategy.get("preferred_extension")
|
||||
if strategy.get("mode") != "extension_first_proposal":
|
||||
findings.append(issue("error", "unsupported_write_strategy", f"Unsupported write strategy: {strategy.get('mode')}"))
|
||||
if not preferred_extension:
|
||||
findings.append(issue("warning", "missing_preferred_extension", "No preferred extension selected; patch generation should create/choose an extension explicitly."))
|
||||
|
||||
policy = proposal.get("target_policy") or {}
|
||||
write_candidates = policy.get("write_candidates") or []
|
||||
references = policy.get("read_only_reference_files") or []
|
||||
if not write_candidates:
|
||||
findings.append(issue("warning", "no_write_candidates", "No write candidates were selected."))
|
||||
for target in write_candidates:
|
||||
findings.extend(check_write_candidate(target, preferred_extension))
|
||||
for target in references:
|
||||
findings.extend(check_reference_target(target))
|
||||
|
||||
forbidden = set(strategy.get("forbidden") or [])
|
||||
required_forbidden = {
|
||||
"direct SQL metadata/data updates",
|
||||
"direct Config/ConfigSave/ConfigCAS writes",
|
||||
"automatic production Designer update/apply",
|
||||
}
|
||||
missing = sorted(required_forbidden - forbidden)
|
||||
if missing:
|
||||
findings.append(issue("error", "missing_forbidden_strategy_items", "Write strategy is missing forbidden items: " + ", ".join(missing)))
|
||||
|
||||
errors = [row for row in findings if row.get("severity") == "error"]
|
||||
warnings = [row for row in findings if row.get("severity") == "warning"]
|
||||
return {
|
||||
"object": proposal.get("object"),
|
||||
"preferred_extension": preferred_extension,
|
||||
"passed": not errors,
|
||||
"findings": findings,
|
||||
"counts": {
|
||||
"errors": len(errors),
|
||||
"warnings": len(warnings),
|
||||
"write_candidates": len(write_candidates),
|
||||
"read_only_references": len(references),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def check(data: dict[str, Any]) -> dict[str, Any]:
|
||||
proposal_checks = [check_proposal(proposal) for proposal in data.get("proposals") or []]
|
||||
errors = sum(item.get("counts", {}).get("errors", 0) for item in proposal_checks)
|
||||
warnings = sum(item.get("counts", {}).get("warnings", 0) for item in proposal_checks)
|
||||
return {
|
||||
"schema": "onec_change_proposal_safety_check.v1",
|
||||
"source_schema": data.get("schema"),
|
||||
"task": data.get("task"),
|
||||
"passed": errors == 0,
|
||||
"proposal_checks": proposal_checks,
|
||||
"required_gates_before_real_write": [
|
||||
"backup_gate",
|
||||
"round_trip_parser_gate",
|
||||
"designer_validation_gate",
|
||||
"saved_state_gate",
|
||||
"extension_packaging_gate",
|
||||
"diff_gate",
|
||||
"minimal_write_scope_gate",
|
||||
"recovery_test_gate",
|
||||
],
|
||||
"counts": {
|
||||
"proposals": len(proposal_checks),
|
||||
"errors": errors,
|
||||
"warnings": warnings,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description="Check 1C proposal safety.")
|
||||
parser.add_argument("--proposal", type=Path, required=True)
|
||||
parser.add_argument("--output", type=Path)
|
||||
args = parser.parse_args()
|
||||
|
||||
result = check(load_json(args.proposal))
|
||||
output = json.dumps(result, ensure_ascii=False, indent=2) + "\n"
|
||||
if args.output:
|
||||
args.output.parent.mkdir(parents=True, exist_ok=True)
|
||||
args.output.write_text(output, encoding="utf-8")
|
||||
print(json.dumps({"output": str(args.output), "passed": result["passed"], "counts": result["counts"]}, ensure_ascii=False))
|
||||
else:
|
||||
print(output)
|
||||
return 0 if result["passed"] else 2
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user