← Files Aivana Dataverse App ArchitectARCHIVED FILE
scripts/validate_solution_against_blueprint.py
8.41 KB · Sep 30, 2026 · 23:15 UTC
#!/usr/bin/env python3
"""Compare declared metadata and UI against actual exported XML; fail closed."""
import argparse
import json
import xml.etree.ElementTree as ET
from pathlib import Path
from blueprint_schema import load_blueprint
TYPES = {"string": {"nvarchar", "string"}, "email": {"nvarchar", "string"},
"url": {"nvarchar", "string"}, "multilinetext": {"ntext", "memo"},
"money": {"money"}, "decimal": {"decimal"}, "integer": {"int", "integer"},
"boolean": {"bit", "boolean"}, "choice": {"picklist"}, "date": {"datetime"},
"datetime": {"datetime"}, "lookup": {"lookup"}, "double": {"float", "double"},
"file": {"file"}, "image": {"image"}, "multiselectchoice": {"multiselectpicklist"}}
def parse(path):
root = ET.parse(path).getroot()
for node in root.iter():
node.tag = node.tag.split("}")[-1]
return root
def labels(root):
return {n.get("description", "").casefold() for n in root.findall("./LocalizedNames/LocalizedName")}
def compare(blueprint, folder):
missing, checks = [], []
folder = Path(folder)
def check(ok, kind, **details):
(checks if ok else missing).append(dict(kind=kind, **details, status="present" if ok else "missing-or-different"))
entity_dirs = {p.name.casefold(): p for p in (folder / "Entities").glob("*") if p.is_dir()}
relationships = []
for path in (folder / "Other/Relationships").glob("*.xml"):
for rel in parse(path).findall(".//EntityRelationship"):
relationships.append((rel.findtext("ReferencingEntityName", "").casefold(),
rel.findtext("ReferencedEntityName", "").casefold(),
rel.findtext("ReferencingAttributeName", "").casefold(),
rel.get("Name", "").casefold()))
field_aliases, page_aliases = {}, {}
for table in blueprint["dataModel"]["tables"]:
logical = table["logicalName"].casefold()
page_aliases[logical] = logical
if table.get("displayName"):
page_aliases[table["displayName"].casefold()] = logical
aliases = field_aliases[logical] = {}
for col in table.get("columns", []):
name = col["logicalName"].casefold()
aliases[name] = name
if col.get("displayName"):
aliases[col["displayName"].casefold()] = name
directory = entity_dirs.get(logical)
exists = directory is not None and (directory / "Entity.xml").is_file()
check(exists, "table", table=logical)
if not exists:
continue
root = parse(directory / "Entity.xml")
columns = {n.findtext("LogicalName", "").casefold(): n for n in root.findall(".//attribute")}
for name, node in columns.items():
aliases[name] = name
for label in node.findall(".//displayname"):
aliases[label.get("description", "").casefold()] = name
for column in table.get("columns", []):
name = column["logicalName"].casefold()
node = columns.get(name)
check(node is not None, "column", table=logical, column=name)
if node is None:
continue
actual = node.findtext("Type", "").casefold()
expected = column["type"].casefold()
check(actual in TYPES.get(expected, {expected}), "columnType", table=logical, column=name, expected=expected, actual=actual)
if expected in {"email", "url"}:
check(node.findtext("Format", "").casefold() == expected, "columnFormat", table=logical, column=name, expected=expected)
if "required" in column:
required = node.findtext("RequiredLevel", "").casefold()
check(required in {"none", "recommended", "required", "applicationrequired", "systemrequired"} and (required in {"required", "applicationrequired", "systemrequired"}) == column["required"], "columnRequired", table=logical, column=name)
for rel in table.get("relationships", []):
pair = (logical, rel["target"].casefold())
if rel.get("type") == "oneToMany":
pair = pair[::-1]
matches = [r for r in relationships if r[:2] == pair]
if rel.get("lookup"):
matches = [r for r in matches if r[2] == rel["lookup"].casefold()]
if rel.get("schemaName"):
matches = [r for r in matches if r[3] == rel["schemaName"].casefold()]
check(bool(matches) and rel.get("type", "manyToOne") in {"manyToOne", "oneToMany"}, "relationship", table=logical, target=rel["target"])
for kind, glob, tag, fieldkey in (("forms", "FormXml/**/*.xml", "systemform", "primaryTaskFields"), ("views", "SavedQueries/*.xml", "savedquery", "columns")):
for expected in blueprint.get(kind, []):
table = expected["table"].casefold()
directory = entity_dirs.get(table)
candidates = []
for path in directory.glob(glob) if directory else []:
candidates.extend(parse(path).findall(".//" + tag))
idtag = "formid" if kind == "forms" else "savedqueryid"
matched = [n for n in candidates if (expected.get("id") and n.findtext(idtag, "").strip("{}").casefold() == expected["id"].strip("{}").casefold()) or (not expected.get("id") and expected["name"].casefold() in labels(n))]
check(len(matched) == 1, kind, table=table, name=expected["name"])
if len(matched) != 1:
continue
node = matched[0]
actual_fields = ({n.get("datafieldname", "").casefold() for n in node.findall(".//control")} if kind == "forms" else {n.get("name", "").casefold() for n in node.findall(".//layoutxml//cell")})
for field in expected.get(fieldkey, []):
resolved = field_aliases.get(table, {}).get(field.casefold(), field.casefold())
check(resolved in actual_fields, kind + "Field", table=table, name=expected["name"], field=field)
if kind == "views" and expected.get("defaultSort"):
parts = expected["defaultSort"].rsplit(" ", 1)
field = field_aliases.get(table, {}).get(parts[0].casefold(), parts[0].casefold())
orders = node.findall(".//fetchxml/fetch/entity/order")
check(bool(orders) and orders[0].get("attribute", "").casefold() == field and orders[0].get("descending", "false") == ("true" if len(parts) == 2 and parts[1].lower() == "desc" else "false"), "viewSort", table=table, name=expected["name"])
subareas = []
for path in (folder / "AppModuleSiteMaps").rglob("*.xml"):
subareas.extend(parse(path).findall(".//SubArea"))
check(bool(subareas), "appNavigation")
for group in blueprint.get("navigation", []):
pages = [group["table"]] if group.get("table") else group.get("pages", [])
for page in pages:
if isinstance(page, dict):
key, attr = (page.get("table"), "Entity") if page.get("table") else (page.get("url"), "Url")
else:
key, attr = page_aliases.get(page.casefold(), page.casefold()), "Entity"
check(bool(key) and any(n.get(attr, "").casefold() == key.casefold() for n in subareas), "navigationPage", page=page)
for page in blueprint.get("customPages", []):
check(False, "customPageEvidenceRequired", page=page)
return {"valid": not missing, "missing": missing, "checks": checks, "repairRequired": bool(missing),
"scope": "Declared tables, columns, types, required levels, relationships, forms, views and sitemap pages. Runtime role/accessibility/UAT evidence remains separate.",
"nextAction": "Repair differences and export again." if missing else "Declared supported metadata matches; complete runtime and release gates separately."}
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("blueprint", type=Path)
parser.add_argument("solution_folder", type=Path)
parser.add_argument("--json", action="store_true", dest="as_json")
args = parser.parse_args()
try:
report = compare(load_blueprint(args.blueprint), args.solution_folder)
except (OSError, ValueError, ET.ParseError, TypeError) as exc:
report = {"valid": False, "missing": [], "checks": [], "errors": [str(exc)], "repairRequired": True}
print(json.dumps(report, ensure_ascii=False, indent=2))
return 0 if report["valid"] else 1
if __name__ == "__main__":
raise SystemExit(main())
SHA-256: 032bf51db2ec245d1f7d0c3d7db9252c42a862eb1289dca939995de3918f0cd2