diff --git a/checks/check_series_permutations.py b/checks/check_series_permutations.py
index 4f3de56..14339d6 100644
--- a/checks/check_series_permutations.py
+++ b/checks/check_series_permutations.py
@@ -1,17 +1,31 @@
import os
+import re
import json
from typing import Dict, List, Set, Tuple, Any
+def _is_ranger_model(linking_data: dict) -> bool:
+ """
+ Detect if this is a Ranger (CV) model pack by checking header.ptvl.
+ Ranger packs use SE#/ABM codes instead of VS/ACM codes.
+
+ :param linking_data: Parsed linkingrecord.json data
+ :return: True if this is a Ranger/CV model, False otherwise
+ """
+ ptvl = linking_data.get('header', {}).get('ptvl', '')
+ return bool(re.match(r'^TR[AB]\d{2}$', str(ptvl), re.IGNORECASE))
+
+
def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
"""
- Validate that series images exist for every unique VS/ACM combination found in exterior and showroom sections.
+ Validate that series images exist for every unique VS/ACM (or SE#/ABM for Ranger) combination
+ found in exterior and showroom sections.
- This check validates that the "series" section contains an image for every unique combination of
- VS- (Visual Selection) and ACM (Accessory Content Management) codes that appears in the
- exterior and showroom sections. The validation creates a "source of truth" by extracting all
- unique [ACM, VS] pairs from exterior and showroom records, then ensures the series section
- provides an image for each combination.
+ For standard packs: validates VS- (Visual Selection) and ACM (Accessory Content Management) pairs.
+ For Ranger/CV packs (header.ptvl matches TR[AB]##): validates SE# and ABM code pairs instead.
+
+ The validation creates a "source of truth" by extracting all unique code pairs from exterior
+ and showroom records, then ensures the series section provides an image for each combination.
:param config: Configuration dictionary containing:
- working_dir: Directory where linkingrecord.json and extracted files are located
@@ -62,7 +76,34 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
"error_message": "Invalid linkingrecord.json structure: 'items' missing or not a list."
}
- # STEP 1-3: Build "Source of Truth" - Extract unique [ACM, VS] pairs from exterior and showroom
+ # Detect model type to determine which code prefixes to use
+ is_ranger = _is_ranger_model(linkingrecord)
+
+ if is_ranger:
+ # CV/Ranger model: uses SE# + ABM codes
+ primary_prefix = "se#"
+ secondary_prefix = "abm"
+ code_label = "SE#/ABM"
+ else:
+ # Standard model: uses VS + ACM codes
+ primary_prefix = "vs-"
+ secondary_prefix = "acm"
+ code_label = "VS/ACM"
+
+ def _extract_code_pair(features):
+ """Extract the primary (VS/SE#) and secondary (ACM/ABM) code from a feature list."""
+ primary_code = None
+ secondary_code = None
+ for feature in features:
+ if isinstance(feature, str):
+ fl = feature.lower()
+ if fl.startswith(primary_prefix):
+ primary_code = feature
+ elif fl.startswith(secondary_prefix):
+ secondary_code = feature
+ return primary_code, secondary_code
+
+ # STEP 1-3: Build "Source of Truth" - Extract unique code pairs from exterior and showroom
source_of_truth = set()
for item in linkingrecord["items"]:
@@ -98,33 +139,26 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(features, list):
continue
- # Extract the actual [ACM, VS] pair from this record's features
+ # Extract the code pair from this record's features
# Each record represents ONE combination, not a cross-product
- vs_code = None
- acm_code = None
+ primary_code, secondary_code = _extract_code_pair(features)
- for feature in features:
- if isinstance(feature, str):
- if feature.startswith("vs-"):
- vs_code = feature
- elif feature.startswith("acm"):
- acm_code = feature
-
- # If this record has both ACM and VS, add the pair to source of truth
- if vs_code and acm_code:
- source_of_truth.add((acm_code, vs_code))
+ # If this record has both codes, add the pair to source of truth
+ if primary_code and secondary_code:
+ source_of_truth.add((secondary_code, primary_code))
# Check if we found any valid combinations
if not source_of_truth:
return {
"status": "passed",
"details": {
- "message": "No VS/ACM combinations found in exterior or showroom sections. Check may not be applicable to this pack type.",
- "source_combinations_count": 0
+ "message": f"No {code_label} combinations found in exterior or showroom sections. Check may not be applicable to this pack type.",
+ "source_combinations_count": 0,
+ "model_type": "Ranger/CV" if is_ranger else "Standard"
}
}
- # STEP 4: Extract actual [ACM, VS] pairs from series section
+ # STEP 4: Extract actual code pairs from series section
series_combinations = set()
series_section_found = False
@@ -154,30 +188,22 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
if not isinstance(features, list):
continue
- # Extract the actual [ACM, VS] pair from this series record
- # Each series record represents ONE combination, not a cross-product
- vs_code = None
- acm_code = None
+ # Extract the code pair from this series record
+ primary_code, secondary_code = _extract_code_pair(features)
- for feature in features:
- if isinstance(feature, str):
- if feature.startswith("vs-"):
- vs_code = feature
- elif feature.startswith("acm"):
- acm_code = feature
-
- # If this record has both ACM and VS, add the pair
- if vs_code and acm_code:
- series_combinations.add((acm_code, vs_code))
+ # If this record has both codes, add the pair
+ if primary_code and secondary_code:
+ series_combinations.add((secondary_code, primary_code))
# Check if series section exists
if not series_section_found:
return {
"status": "failed",
"details": {
- "message": "No series section found (viewtype='carousel', imagetype='series') in linkingrecord.",
+ "message": f"No series section found (viewtype='carousel', imagetype='series') in linkingrecord.",
"source_combinations_count": len(source_of_truth),
- "source_combinations": [f"({acm}, {vs})" for acm, vs in sorted(source_of_truth)]
+ "model_type": "Ranger/CV" if is_ranger else "Standard",
+ "source_combinations": [f"({sec}, {pri})" for sec, pri in sorted(source_of_truth)]
}
}
@@ -188,17 +214,18 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
# Generate detailed report
if missing_combinations:
error_details = {
- "message": "Series validation failed: Some VS/ACM combinations from exterior/showroom are missing series images.",
+ "message": f"Series validation failed: Some {code_label} combinations from exterior/showroom are missing series images.",
+ "model_type": "Ranger/CV" if is_ranger else "Standard",
"source_combinations_count": len(source_of_truth),
"series_combinations_count": len(series_combinations),
"missing_count": len(missing_combinations),
- "missing_combinations": [f"({acm}, {vs})" for acm, vs in sorted(missing_combinations)]
+ "missing_combinations": [f"({sec}, {pri})" for sec, pri in sorted(missing_combinations)]
}
if extra_combinations:
error_details["extra_count"] = len(extra_combinations)
- error_details["extra_combinations"] = [f"({acm}, {vs})" for acm, vs in sorted(extra_combinations)]
- error_details["extra_note"] = "Series section contains combinations not found in exterior/showroom."
+ error_details["extra_combinations"] = [f"({sec}, {pri})" for sec, pri in sorted(extra_combinations)]
+ error_details["extra_note"] = f"Series section contains {code_label} combinations not found in exterior/showroom."
return {
"status": "failed",
@@ -207,14 +234,15 @@ def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
# All required combinations are present in series
success_details = {
- "message": "All VS/ACM combinations from exterior and showroom have corresponding series images.",
+ "message": f"All {code_label} combinations from exterior and showroom have corresponding series images.",
+ "model_type": "Ranger/CV" if is_ranger else "Standard",
"validated_combinations_count": len(source_of_truth),
- "source_combinations": [f"({acm}, {vs})" for acm, vs in sorted(source_of_truth)]
+ "source_combinations": [f"({sec}, {pri})" for sec, pri in sorted(source_of_truth)]
}
if extra_combinations:
success_details["note"] = f"{len(extra_combinations)} additional series combinations found that don't appear in exterior/showroom."
- success_details["extra_combinations"] = [f"({acm}, {vs})" for acm, vs in sorted(extra_combinations)]
+ success_details["extra_combinations"] = [f"({sec}, {pri})" for sec, pri in sorted(extra_combinations)]
return {
"status": "passed",
diff --git a/checks/exterior_interior_pairing_check.py b/checks/exterior_interior_pairing_check.py
new file mode 100644
index 0000000..2a286bf
--- /dev/null
+++ b/checks/exterior_interior_pairing_check.py
@@ -0,0 +1,221 @@
+import os
+import json
+from typing import Dict, Any, FrozenSet, Set, List
+
+
+def run_check(config: Dict[str, Any]) -> Dict[str, Any]:
+ """
+ Validate that every exterior base asset has a matching interior base asset with the same
+ feature set (excluding paint and trim codes), and vice versa.
+
+ For each exterior item (viewtype="exterior", base imagetype/layer), the set of features
+ minus paint codes must match at least one interior item's feature set minus trim codes.
+ Both directions are checked: exterior items without an interior partner, and interior items
+ without an exterior partner.
+
+ For MEC packs, items with experienceCondition="2d-background" are validated separately.
+
+ :param config: Configuration dictionary containing:
+ - working_dir: Directory where linkingrecord.json is located
+ - linkingrecord_filename: The linking record filename (default: 'linkingrecord.json')
+ - paint_code_prefix: Prefix for paint codes to exclude from exterior features (default: 'pn')
+ - trim_code_prefix: Prefix for trim codes to exclude from interior features (default: 'tr-')
+
+ :return: Dictionary with validation results:
+ - status: "passed", "failed", or "error"
+ - details: Breakdown of mismatches by direction and MEC condition
+ """
+ working_dir = config.get("working_dir", "working")
+ linkingrecord_filename = config.get("linkingrecord_filename", "linkingrecord.json")
+ paint_prefix = config.get("paint_code_prefix", "pn").lower()
+ trim_prefix = config.get("trim_code_prefix", "tr-").lower()
+ linkingrecord_path = os.path.join(working_dir, linkingrecord_filename)
+
+ if not os.path.exists(linkingrecord_path):
+ return {
+ "status": "error",
+ "error_message": f"Linking record file '{linkingrecord_filename}' not found in {working_dir}."
+ }
+
+ try:
+ with open(linkingrecord_path, 'r', encoding='utf-8') as f:
+ linkingrecord = json.load(f)
+ except json.JSONDecodeError as e:
+ return {
+ "status": "error",
+ "error_message": f"Malformed JSON in '{linkingrecord_filename}': {str(e)}"
+ }
+ except Exception as e:
+ return {
+ "status": "error",
+ "error_message": f"Error reading '{linkingrecord_filename}': {str(e)}"
+ }
+
+ if not isinstance(linkingrecord, dict):
+ return {
+ "status": "error",
+ "error_message": "Invalid linkingrecord.json structure: root is not an object."
+ }
+
+ if "items" not in linkingrecord or not isinstance(linkingrecord["items"], list):
+ return {
+ "status": "error",
+ "error_message": "Invalid linkingrecord.json structure: 'items' missing or not a list."
+ }
+
+ def _is_base_item(item: dict) -> bool:
+ """A base item has no imagetype (None) or imagetype='base', and layer=0 or no layer."""
+ conditions = item.get("conditions", {})
+ if not isinstance(conditions, dict):
+ return False
+ imagetype = conditions.get("imagetype")
+ layer = conditions.get("layer", 0)
+ return imagetype in (None, "base", "") and (layer == 0 or layer is None)
+
+ def _strip_paint_codes(features: List[str]) -> FrozenSet[str]:
+ """Remove paint codes (pn prefix) from exterior feature list."""
+ return frozenset(f for f in features if isinstance(f, str) and not f.lower().startswith(paint_prefix))
+
+ def _strip_trim_codes(features: List[str]) -> FrozenSet[str]:
+ """Remove trim codes (configurable prefix) from interior feature list."""
+ return frozenset(f for f in features if isinstance(f, str) and not f.lower().startswith(trim_prefix))
+
+ def _collect_feature_sets(items_list, viewtype_val: str, strip_fn, mec_only: bool = False) -> Set[FrozenSet[str]]:
+ """Collect normalised feature sets for all base records of the given viewtype."""
+ result = set()
+ for item in items_list:
+ if not isinstance(item, dict):
+ continue
+ conditions = item.get("conditions", {})
+ if not isinstance(conditions, dict):
+ continue
+ if conditions.get("viewtype") != viewtype_val:
+ continue
+ if not _is_base_item(item):
+ continue
+ exp_condition = conditions.get("experienceCondition", "")
+ is_mec = exp_condition == "2d-background"
+ if mec_only and not is_mec:
+ continue
+ if not mec_only and is_mec:
+ continue
+ for record in item.get("records", []):
+ if not isinstance(record, dict):
+ continue
+ features = record.get("features", [])
+ if not isinstance(features, list):
+ continue
+ normalised = strip_fn(features)
+ if normalised:
+ result.add(normalised)
+ return result
+
+ def _run_pairing_check(items_list, mec_only: bool = False) -> dict:
+ """Compare exterior and interior feature sets and return mismatch details."""
+ ext_sets = _collect_feature_sets(items_list, "exterior", _strip_paint_codes, mec_only)
+ int_sets = _collect_feature_sets(items_list, "interior", _strip_trim_codes, mec_only)
+
+ if not ext_sets and not int_sets:
+ return {"applicable": False}
+
+ ext_only = ext_sets - int_sets
+ int_only = int_sets - ext_sets
+
+ return {
+ "applicable": True,
+ "exterior_count": len(ext_sets),
+ "interior_count": len(int_sets),
+ "exterior_without_interior": len(ext_only),
+ "interior_without_exterior": len(int_only),
+ "ext_only_feature_sets": [sorted(list(s)) for s in sorted(ext_only, key=lambda x: sorted(x))],
+ "int_only_feature_sets": [sorted(list(s)) for s in sorted(int_only, key=lambda x: sorted(x))],
+ }
+
+ items = linkingrecord["items"]
+
+ # Standard pairing check (excluding 2d-background MEC items)
+ standard = _run_pairing_check(items, mec_only=False)
+
+ # MEC 2d-background pairing check
+ mec = _run_pairing_check(items, mec_only=True)
+
+ # Determine if any items had the MEC condition
+ has_mec_items = mec.get("applicable", False)
+
+ # Evaluate overall pass/fail
+ standard_pass = not standard.get("applicable") or (
+ standard.get("exterior_without_interior", 0) == 0 and
+ standard.get("interior_without_exterior", 0) == 0
+ )
+ mec_pass = not mec.get("applicable") or (
+ mec.get("exterior_without_interior", 0) == 0 and
+ mec.get("interior_without_exterior", 0) == 0
+ )
+
+ if not standard.get("applicable") and not has_mec_items:
+ return {
+ "status": "passed",
+ "details": {
+ "message": "No exterior or interior base items found. Check not applicable to this pack.",
+ "applicable": False
+ }
+ }
+
+ overall_pass = standard_pass and mec_pass
+ status = "passed" if overall_pass else "failed"
+
+ details: Dict[str, Any] = {}
+
+ if standard.get("applicable"):
+ details["standard"] = {
+ "exterior_base_sets": standard["exterior_count"],
+ "interior_base_sets": standard["interior_count"],
+ "exterior_without_interior_partner": standard["exterior_without_interior"],
+ "interior_without_exterior_partner": standard["interior_without_exterior"],
+ }
+ if standard["exterior_without_interior"] > 0:
+ details["standard"]["exterior_only_feature_sets"] = standard["ext_only_feature_sets"]
+ if standard["interior_without_exterior"] > 0:
+ details["standard"]["interior_only_feature_sets"] = standard["int_only_feature_sets"]
+
+ if has_mec_items:
+ details["mec_2d_background"] = {
+ "exterior_base_sets": mec["exterior_count"],
+ "interior_base_sets": mec["interior_count"],
+ "exterior_without_interior_partner": mec["exterior_without_interior"],
+ "interior_without_exterior_partner": mec["interior_without_exterior"],
+ }
+ if mec["exterior_without_interior"] > 0:
+ details["mec_2d_background"]["exterior_only_feature_sets"] = mec["ext_only_feature_sets"]
+ if mec["interior_without_exterior"] > 0:
+ details["mec_2d_background"]["interior_only_feature_sets"] = mec["int_only_feature_sets"]
+
+ if overall_pass:
+ msg_parts = []
+ if standard.get("applicable"):
+ msg_parts.append(f"all {standard['exterior_count']} exterior/interior base pairs matched")
+ if has_mec_items:
+ msg_parts.append(f"all {mec['exterior_count']} MEC 2d-background pairs matched")
+ details["message"] = "Exterior/interior pairing passed: " + "; ".join(msg_parts) + "."
+ else:
+ msg_parts = []
+ if standard.get("applicable") and not standard_pass:
+ ext_miss = standard["exterior_without_interior"]
+ int_miss = standard["interior_without_exterior"]
+ if ext_miss:
+ msg_parts.append(f"{ext_miss} exterior base set(s) with no interior partner")
+ if int_miss:
+ msg_parts.append(f"{int_miss} interior base set(s) with no exterior partner")
+ if has_mec_items and not mec_pass:
+ ext_miss = mec["exterior_without_interior"]
+ int_miss = mec["interior_without_exterior"]
+ if ext_miss:
+ msg_parts.append(f"{ext_miss} MEC exterior base set(s) with no interior partner")
+ if int_miss:
+ msg_parts.append(f"{int_miss} MEC interior base set(s) with no exterior partner")
+ details["message"] = "Exterior/interior pairing failed: " + "; ".join(msg_parts) + "."
+
+ details["paint_code_prefix"] = paint_prefix
+ details["trim_code_prefix"] = trim_prefix
+
+ return {"status": status, "details": details}
diff --git a/checks/html_reporter.py b/checks/html_reporter.py
index 2ada65b..8e68540 100755
--- a/checks/html_reporter.py
+++ b/checks/html_reporter.py
@@ -172,7 +172,9 @@ class HTMLReporter:
"image_linking_check": "Image Link References Check",
"business_data_check": "Business Data Validation",
"image_format_check": "Image Format Validation",
- "layer_depth_check": "Layer Depth Validation"
+ "layer_depth_check": "Layer Depth Validation",
+ "check_series_permutations": "Series Permutations Validation",
+ "exterior_interior_pairing_check": "Exterior / Interior Base Asset Pairing"
}
# Return the friendly name or format the module name if not in the mapping
@@ -326,6 +328,8 @@ class HTMLReporter:
elif 'record_results' in details and 'overall_coverage' in details:
# Extra carousel validation check
formatted_parts.append(HTMLReporter._format_extra_carousel_validation(details))
+ elif check_type == "exterior_interior_pairing_check" or 'standard' in details or 'mec_2d_background' in details:
+ formatted_parts.append(HTMLReporter._format_exterior_interior_pairing(details))
# If no special formatting applied so far, use generic formatting
elif not formatted_parts:
formatted_parts.append(f'''
@@ -1576,6 +1580,107 @@ class HTMLReporter:
'''
+ @staticmethod
+ def _format_exterior_interior_pairing(details: dict) -> str:
+ """Format exterior/interior base asset pairing check results."""
+ try:
+ def _feature_set_table(feature_sets: list, label: str, badge_class: str) -> str:
+ if not feature_sets:
+ return ""
+ rows = "".join(
+ f"
| {i+1} | {', '.join(fs)} |
"
+ for i, fs in enumerate(feature_sets)
+ )
+ return f'''
+
+
{label} ({len(feature_sets)})
+
+
+ | # | Feature Set (normalised) |
+
+ {rows}
+
+
+ '''
+
+ def _render_section(section_data: dict, title: str) -> str:
+ if not section_data:
+ return ""
+ ext_count = section_data.get("exterior_base_sets", 0)
+ int_count = section_data.get("interior_base_sets", 0)
+ ext_miss = section_data.get("exterior_without_interior_partner", 0)
+ int_miss = section_data.get("interior_without_exterior_partner", 0)
+ passed = ext_miss == 0 and int_miss == 0
+ header_class = "table-success" if passed else "table-danger"
+
+ summary = f'''
+
+
{title}
+
+
+
+ | Exterior base sets | {ext_count} |
+ | Interior base sets | {int_count} |
+ | Exterior without interior partner |
+ {ext_miss} |
+ | Interior without exterior partner |
+ {int_miss} |
+
+
+ '''
+
+ tables = ""
+ if "exterior_only_feature_sets" in section_data:
+ tables += _feature_set_table(
+ section_data["exterior_only_feature_sets"],
+ "Exterior Sets Without an Interior Partner",
+ "table-danger"
+ )
+ if "interior_only_feature_sets" in section_data:
+ tables += _feature_set_table(
+ section_data["interior_only_feature_sets"],
+ "Interior Sets Without an Exterior Partner",
+ "table-warning"
+ )
+
+ return summary + tables + "
"
+
+ parts = []
+
+ standard = details.get("standard")
+ if standard:
+ parts.append(_render_section(standard, "Standard Base Items"))
+
+ mec = details.get("mec_2d_background")
+ if mec:
+ parts.append(_render_section(mec, "MEC 2D-Background Items"))
+
+ if not parts:
+ return 'No pairing data available.
'
+
+ paint_prefix = details.get("paint_code_prefix", "pn")
+ trim_prefix = details.get("trim_code_prefix", "tr-")
+ parts.append(f'''
+
+ Paint codes excluded from exterior features: prefix {paint_prefix} |
+ Trim codes excluded from interior features: prefix {trim_prefix}
+
+ ''')
+
+ return "".join(parts)
+
+ except Exception as e:
+ logging.warning(f"Error formatting exterior/interior pairing: {e}")
+ return f'''
+
+ Error formatting exterior/interior pairing: {str(e)}
+
+ {json.dumps(details, indent=2, default=str)}
+ '''
+
+
if __name__ == "__main__":
import sys
if len(sys.argv) != 3:
diff --git a/checks/unzip_and_verify_check.py b/checks/unzip_and_verify_check.py
index c58fab6..99da929 100755
--- a/checks/unzip_and_verify_check.py
+++ b/checks/unzip_and_verify_check.py
@@ -107,6 +107,15 @@ def validate_linkingrecord_header(linkingrecord_data):
"error": "Must be a string value"
})
+ # Validate fullLoad (must always be "Y")
+ full_load = header.get("fullLoad", "")
+ if full_load != "Y":
+ errors.append({
+ "field": "fullLoad",
+ "value": full_load,
+ "error": "Must be exactly 'Y' (full load packs only)"
+ })
+
return len(errors) == 0, errors
def validate_linking_record_filetypes(linkingrecord_data):
diff --git a/profiles/ford_bnp.json b/profiles/ford_bnp.json
index 99459d7..189ba61 100755
--- a/profiles/ford_bnp.json
+++ b/profiles/ford_bnp.json
@@ -97,5 +97,14 @@
"working_dir": "__WORKING_DIR__",
"linkingrecord_filename": "linkingrecord.json"
}
+ },
+ {
+ "script": "checks.exterior_interior_pairing_check",
+ "config": {
+ "working_dir": "__WORKING_DIR__",
+ "linkingrecord_filename": "linkingrecord.json",
+ "paint_code_prefix": "pn",
+ "trim_code_prefix": "tr-"
+ }
}
]
\ No newline at end of file