Files
Cesarus85--ringfall-mjolnir-vr/tools/regression_gate.py
T

1441 lines
54 KiB
Python

"""Fail-closed whole-translation-unit regression manifests for COFF-i386.
The ordinary objdiff report is useful for navigation, but it is not an
acceptance oracle. This gate freezes every accepted function plus the full
non-code and symbol ownership shape of a translation unit. A later check
rebuilds the selected objects and reports any loss of that evidence.
The gate deliberately does not grant matching credit. In particular,
``NEWLY_EXACT`` is only a review lead; the normal reviewed acceptance workflow
must update the project metadata before a new clean snapshot is taken.
"""
from __future__ import annotations
import argparse
import copy
import datetime as _datetime
import hashlib
import json
import os
import re
import struct
import subprocess
import sys
from pathlib import Path
from typing import Any, Dict, Iterable, List, Mapping, Optional, Sequence, Tuple
from .coff_compare import (
CoffError,
IMAGE_SCN_CNT_CODE,
IMAGE_SCN_CNT_UNINITIALIZED_DATA,
RELOC_ENTRY_SIZE,
SYMBOL_ENTRY_SIZE,
image_symbol_addresses,
load,
section_info,
section_info_by_number,
section_info_resolved,
section_infos_equal,
)
from .semantic_progress import _verify_local_label_continuation
from .audit_semantic_matches import relocation_shape_matches
SCHEMA_VERSION = 1
ADJUDICATION_SCHEMA_VERSION = 1
XDK_D3DINLINE_RECIPE = "xdk-stock-d3dinline"
IMAGE_SCN_CNT_INITIALIZED_DATA = 0x00000040
IMAGE_SCN_LNK_COMDAT = 0x00001000
IMAGE_SYM_CLASS_STATIC = 0x03
SECTION_HEADER_SIZE = 40
COFF_HEADER_SIZE = 20
BSS_SENTINEL = "BSS-NO-RAW"
class GateError(RuntimeError):
"""An input cannot be measured or classified without guessing."""
def _json_hash(value: Any) -> str:
raw = json.dumps(
value, sort_keys=True, separators=(",", ":"), ensure_ascii=True
).encode("utf-8")
return hashlib.sha256(raw).hexdigest()
def _file_sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def _read_json(path: Path) -> Any:
try:
return json.loads(path.read_text(encoding="utf-8"))
except FileNotFoundError as error:
raise GateError(f"required file is missing: {path}") from error
except json.JSONDecodeError as error:
raise GateError(f"invalid JSON in {path}: {error}") from error
def _resolve_path(root: Path, path: Path) -> Path:
return path if path.is_absolute() else root / path
def _git(root: Path, *arguments: str) -> str:
process = subprocess.run(
["git", *arguments],
cwd=root,
capture_output=True,
text=True,
check=False,
)
if process.returncode:
detail = (process.stderr or process.stdout).strip()
raise GateError(f"git {' '.join(arguments)} failed: {detail}")
return process.stdout.strip()
def require_clean_commit(root: Path) -> str:
"""Return HEAD only when tracked and untracked production state is clean."""
commit = _git(root, "rev-parse", "HEAD")
status = _git(root, "status", "--porcelain", "--untracked-files=normal")
if status:
raise GateError(
"snapshot requires a clean working tree at a known commit; "
f"git status reported:\n{status}"
)
return commit
def _symbol_record(symbol: Mapping[str, Any], section_id: Optional[str]) -> Dict[str, Any]:
return {
"name": symbol["name"],
"value": int(symbol["value"]),
"section": int(symbol["section"]),
"section_id": section_id,
"type": int(symbol["type"]),
"storage": int(symbol["storage"]),
}
def _section_owner_names(obj: Mapping[str, Any], section_number: int) -> List[str]:
section_name = obj["sections"][section_number - 1]["name"]
names = {
item["name"]
for item in obj["symbols"]
if item["section"] == section_number
and item["value"] == 0
and item["name"] != section_name
and not item["name"].startswith(".debug")
}
return sorted(names)
def _section_identities(obj: Mapping[str, Any]) -> Dict[int, str]:
"""Give each section a deterministic ownership-oriented identity.
Owned COMDAT/data sections use their complete value-zero owner set. Truly
anonymous repeated sections retain an ordinal. An ownership change then
changes the identity and is reported as a vanished/appeared section rather
than being paired by a fragile object-local section number.
"""
anonymous_counts: Dict[str, int] = {}
identities: Dict[int, str] = {}
used: Dict[str, int] = {}
for section in obj["sections"]:
owners = _section_owner_names(obj, section["index"])
if owners:
identity = f"{section['name']}|owners=" + ",".join(owners)
else:
ordinal = anonymous_counts.get(section["name"], 0)
anonymous_counts[section["name"]] = ordinal + 1
identity = f"{section['name']}|anonymous={ordinal}"
duplicate = used.get(identity, 0)
used[identity] = duplicate + 1
if duplicate:
identity = f"{identity}|duplicate={duplicate}"
identities[section["index"]] = identity
return identities
def _section_aux_selection(obj: Mapping[str, Any], section_number: int) -> Optional[int]:
"""Read IMAGE_AUX_SYMBOL_SECTION.Selection without weakening coff_compare."""
candidates = [
item
for item in obj["symbols"]
if item["section"] == section_number
and item["storage"] == IMAGE_SYM_CLASS_STATIC
and item["name"] == obj["sections"][section_number - 1]["name"]
]
if len(candidates) != 1:
return None
symbol = candidates[0]
data = obj["data"]
_, _, _, symbol_offset, symbol_count, _, _ = struct.unpack_from(
"<HHLLLHH", data, 0
)
aux_index = int(symbol["index"]) + 1
if aux_index >= symbol_count:
return None
aux_offset = symbol_offset + aux_index * SYMBOL_ENTRY_SIZE
if aux_offset + SYMBOL_ENTRY_SIZE > len(data):
return None
return int(data[aux_offset + 14])
def _section_kind(section: Mapping[str, Any]) -> str:
flags = int(section["flags"])
if flags & IMAGE_SCN_CNT_CODE:
return "CODE"
if flags & IMAGE_SCN_CNT_UNINITIALIZED_DATA:
return "BSS"
if section["name"].startswith(".rdata"):
return "RDATA"
if flags & IMAGE_SCN_CNT_INITIALIZED_DATA:
return "DATA"
if section["name"].startswith(".debug"):
return "DEBUG"
if section["name"] == ".drectve":
return "DIRECTIVE"
return "OTHER"
def _section_bytes(obj: Mapping[str, Any], section: Mapping[str, Any]) -> bytearray:
if (
int(section["flags"]) & IMAGE_SCN_CNT_UNINITIALIZED_DATA
and int(section["raw"]) == 0
):
return bytearray(int(section["size"]))
start = int(section["raw"])
end = start + int(section["size"])
if start < 0 or end > len(obj["data"]):
raise GateError(
f"section {section['index']} raw bytes are outside the COFF object"
)
return bytearray(obj["data"][start:end])
def _resolved_relocations(
obj: Mapping[str, Any],
section: Mapping[str, Any],
identities: Mapping[int, str],
) -> Tuple[str, List[Dict[str, Any]]]:
"""Return a full normalized hash and ordered relocation fingerprint.
Both the resolved destination and the un-normalized owning symbol are
retained. Consequently semantic destination equivalence never hides a
storage/ownership regression.
"""
raw = _section_bytes(obj, section)
try:
semantic_info = section_info_by_number(obj, int(section["index"]))
except CoffError as error:
raise GateError(
f"cannot semantically resolve section {section['index']}: {error}"
) from error
relocations: List[Dict[str, Any]] = []
for relocation_index in range(int(section["reloc_count"])):
relocation_offset = int(section["reloc"]) + relocation_index * RELOC_ENTRY_SIZE
if relocation_offset + RELOC_ENTRY_SIZE > len(obj["data"]):
raise GateError(
f"section {section['index']} relocation table is truncated"
)
address, target_index, relocation_type = struct.unpack_from(
"<LLH", obj["data"], relocation_offset
)
target = obj["by_index"].get(target_index)
if target is None:
raise GateError(
f"section {section['index']} relocation {relocation_index} "
f"references missing symbol index {target_index}"
)
if address + 4 > len(raw):
raise GateError(
f"section {section['index']} relocation {relocation_index} "
f"address {address:#x} is outside its logical size"
)
addend = struct.unpack_from("<i", raw, address)[0]
semantic = semantic_info["relocations"][relocation_index]
if (
int(semantic["address"]) != int(address)
or int(semantic["type"]) != int(relocation_type)
):
raise GateError(
f"hardened relocation resolver order mismatch in section "
f"{section['index']} at relocation {relocation_index}"
)
target_section = int(target["section"])
relocations.append(
{
"address": int(address),
"type": int(relocation_type),
"addend": int(addend),
"resolved_destination": copy.deepcopy(semantic["target"]),
"symbolic_destination": copy.deepcopy(
semantic.get("symbolic_target")
),
"target_symbol": _symbol_record(
target, identities.get(target_section)
),
}
)
return semantic_info["normalized_sha256"], relocations
def _section_fingerprint(
obj: Mapping[str, Any],
section: Mapping[str, Any],
identities: Mapping[int, str],
image_addresses: Mapping[str, int],
) -> Dict[str, Any]:
digest, relocations = _resolved_relocations(
obj, section, identities
)
kind = _section_kind(section)
if kind == "BSS" and int(section["raw"]) == 0:
digest = BSS_SENTINEL
symbols = [
_symbol_record(item, identities.get(int(item["section"])))
for item in obj["symbols"]
if int(item["section"]) == int(section["index"])
]
symbols.sort(
key=lambda item: (
item["value"], item["name"], item["type"], item["storage"]
)
)
return {
"identity": identities[int(section["index"])],
"name": section["name"],
"index": int(section["index"]),
"kind": kind,
"logical_size": int(section["size"]),
"flags": int(section["flags"]),
"alignment_shift": (int(section["flags"]) >> 20) & 0xF,
"comdat": bool(int(section["flags"]) & IMAGE_SCN_LNK_COMDAT),
"comdat_selection": _section_aux_selection(obj, int(section["index"])),
"raw_present": int(section["raw"]) != 0,
"normalized_sha256": digest,
"relocation_count": int(section["reloc_count"]),
"relocations": relocations,
"symbols": symbols,
}
def _object_fingerprint(
path: Path,
meaningful_sizes: Mapping[str, int],
image_addresses: Mapping[str, int],
require_meaningful_sizes: bool = False,
) -> Dict[str, Any]:
try:
obj = load(path)
except (OSError, CoffError) as error:
raise GateError(f"cannot load COFF object {path}: {error}") from error
identities = _section_identities(obj)
symbol_inventory = [
_symbol_record(item, identities.get(int(item["section"])))
for item in obj["symbols"]
]
symbol_inventory.sort(
key=lambda item: (
item["section"], item["value"], item["name"], item["type"], item["storage"]
)
)
function_symbols: Dict[str, Mapping[str, Any]] = {}
for item in obj["symbols"]:
section_number = int(item["section"])
if section_number <= 0 or int(item["type"]) != 0x20:
continue
section = obj["sections"][section_number - 1]
if not (int(section["flags"]) & IMAGE_SCN_CNT_CODE):
continue
name = item["name"]
if name in function_symbols:
raise GateError(f"ambiguous duplicate function symbol {name!r} in {path}")
function_symbols[name] = item
functions: Dict[str, Any] = {}
for name in sorted(function_symbols):
symbol = function_symbols[name]
section = obj["sections"][int(symbol["section"]) - 1]
fingerprint = _section_fingerprint(
obj, section, identities, image_addresses
)
try:
strict_info = section_info(obj, name)
except CoffError as error:
raise GateError(f"cannot fingerprint function {name} in {path}: {error}") from error
fingerprint["padded_size"] = int(strict_info["size"])
meaningful_size = meaningful_sizes.get(name)
if require_meaningful_sizes and meaningful_size is None:
# csplit can preserve multiple public names for one linked-image
# function even though objdiff reports that body once. Inherit
# the report's meaningful size only when exactly one reported
# function symbol occupies the identical COFF section and offset.
# This resolves a name alias; it neither compares nor credits code.
alias_candidates = [
candidate_name
for candidate_name in meaningful_sizes
if candidate_name in function_symbols
and int(function_symbols[candidate_name]["section"])
== int(symbol["section"])
and int(function_symbols[candidate_name]["value"])
== int(symbol["value"])
]
if len(alias_candidates) != 1:
detail = (
"none" if not alias_candidates
else ", ".join(sorted(alias_candidates))
)
raise GateError(
f"ordinary report has no meaningful size for target function "
f"{name} in {path}; identical-location report candidates: "
f"{detail}"
)
meaningful_size = meaningful_sizes[alias_candidates[0]]
fingerprint["meaningful_size"] = int(
strict_info["size"] if meaningful_size is None else meaningful_size
)
fingerprint["strict_relocations"] = copy.deepcopy(strict_info["relocations"])
functions[name] = fingerprint
non_code_sections: Dict[str, Any] = {}
for section in obj["sections"]:
if int(section["flags"]) & IMAGE_SCN_CNT_CODE:
continue
fingerprint = _section_fingerprint(
obj, section, identities, image_addresses
)
identity = fingerprint["identity"]
if identity in non_code_sections:
raise GateError(f"duplicate non-code section identity {identity!r} in {path}")
non_code_sections[identity] = fingerprint
return {
"object_sha256": _file_sha256(path),
"functions": functions,
"non_code_sections": non_code_sections,
"symbols": symbol_inventory,
}
def _report_meaningful_sizes(report: Mapping[str, Any], unit_name: str) -> Dict[str, int]:
matches = [item for item in report.get("units", []) if item.get("name") == unit_name]
if len(matches) != 1:
raise GateError(
f"expected one report unit {unit_name!r}, found {len(matches)}"
)
result: Dict[str, int] = {}
for function in matches[0].get("functions", []):
name = function.get("name")
if name in result:
raise GateError(f"duplicate report function {unit_name}:{name}")
try:
result[name] = int(function["size"])
except (KeyError, TypeError, ValueError) as error:
raise GateError(
f"invalid meaningful size for {unit_name}:{name}"
) from error
return result
def _report_ordinary_percentages(
report: Mapping[str, Any], unit_name: str
) -> Dict[str, float]:
matches = [item for item in report.get("units", []) if item.get("name") == unit_name]
if len(matches) != 1:
raise GateError(
f"expected one report unit {unit_name!r}, found {len(matches)}"
)
result: Dict[str, float] = {}
for function in matches[0].get("functions", []):
name = function.get("name")
if name in result:
raise GateError(f"duplicate report function {unit_name}:{name}")
try:
result[name] = float(function.get("fuzzy_match_percent", 0.0))
except (TypeError, ValueError) as error:
raise GateError(
f"invalid ordinary percentage for {unit_name}:{name}"
) from error
return result
def _symbol_addresses(entries: Sequence[Mapping[str, Any]]) -> Dict[str, int]:
return image_symbol_addresses(entries)
def _exception_records(
units: Iterable[str],
semantic_matches: Sequence[Mapping[str, Any]],
semantic_data_matches: Sequence[Mapping[str, Any]],
) -> List[Dict[str, Any]]:
selected = set(units)
records: List[Dict[str, Any]] = []
for ledger, entries, item_key in (
("semantic_matches", semantic_matches, "function"),
("semantic_data_matches", semantic_data_matches, "symbol"),
):
for entry in entries:
if entry.get("unit") not in selected:
continue
canonical = copy.deepcopy(entry)
records.append(
{
"ledger": ledger,
"unit": entry["unit"],
"item": entry[item_key],
"identity": _json_hash(canonical),
"entry": canonical,
}
)
records.sort(key=lambda item: (item["unit"], item["ledger"], item["item"]))
return records
def _exceptions_for_unit(
exception_records: Sequence[Mapping[str, Any]], unit_name: str
) -> List[Mapping[str, Any]]:
return [item for item in exception_records if item["unit"] == unit_name]
def _find_section_for_symbol(
obj: Mapping[str, Any], symbol_name: str, fingerprints: Mapping[str, Any]
) -> Optional[str]:
matches = [
item
for item in obj["symbols"]
if item["name"] == symbol_name and int(item["section"]) > 0
]
if len(matches) != 1:
return None
identities = _section_identities(obj)
identity = identities[int(matches[0]["section"])]
return identity if identity in fingerprints else None
def _section_acceptance_view(fingerprint: Mapping[str, Any]) -> Dict[str, Any]:
"""Remove only object-local numeric section identifiers for cross-object pairing."""
result = copy.deepcopy(fingerprint)
result.pop("index", None)
for symbol in result.get("symbols", []):
symbol.pop("section", None)
for relocation in result.get("relocations", []):
relocation.get("target_symbol", {}).pop("section", None)
return result
def _capture_unit(
root: Path,
unit_config: Mapping[str, Any],
meaningful_sizes: Mapping[str, int],
image_addresses: Mapping[str, int],
exception_records: Sequence[Mapping[str, Any]],
ordinary_percentages: Optional[Mapping[str, float]] = None,
) -> Dict[str, Any]:
unit_name = unit_config["name"]
target_path = _resolve_path(root, Path(unit_config["target_path"]))
base_path = _resolve_path(root, Path(unit_config["base_path"]))
if not target_path.is_file():
raise GateError(f"target object is missing for {unit_name}: {target_path}")
if not base_path.is_file():
raise GateError(f"base object is missing for {unit_name}: {base_path}")
target = _object_fingerprint(
target_path,
meaningful_sizes,
image_addresses,
require_meaningful_sizes=True,
)
base = _object_fingerprint(base_path, meaningful_sizes, image_addresses)
target_obj = load(target_path)
base_obj = load(base_path)
unit_exceptions = _exceptions_for_unit(exception_records, unit_name)
function_exceptions = {
item["item"]: item
for item in unit_exceptions
if item["ledger"] == "semantic_matches"
}
data_exceptions = {
item["item"]: item
for item in unit_exceptions
if item["ledger"] == "semantic_data_matches"
}
comparisons: Dict[str, Any] = {}
ordinary_percentages = ordinary_percentages or {}
for name, target_fingerprint in target["functions"].items():
state = "ABSENT_IN_BASE"
accepted = False
exception_identity = None
exception = function_exceptions.get(name)
base_name = (
exception["entry"].get("base_function", name)
if exception is not None
else name
)
base_fingerprint = base["functions"].get(base_name)
if base_fingerprint is not None:
try:
target_info = section_info(target_obj, name)
base_info = section_info(base_obj, base_name)
exact = section_infos_equal(target_info, base_info)
except CoffError as error:
raise GateError(
f"cannot compare function {unit_name}:{name}: {error}"
) from error
if exact:
state = "STRICT_EXACT"
elif relocation_shape_matches(target_info, base_info):
state = "OWNERSHIP_ONLY"
elif (
target_info["size"] == base_info["size"]
and target_info["normalized_sha256"]
== base_info["normalized_sha256"]
and target_info["relocation_count"]
== base_info["relocation_count"]
):
state = "BYTES_EQUAL_RELOCATION_SHAPE_DIFF"
else:
state = "NONEXACT"
accepted = exact
# Preserve the existing ordinary acceptance policy without
# pretending it is strict equality. These entries are objdiff
# 100% and have equal normalized bytes plus relocation addresses
# and types, but their resolved destinations remain strict-mismatch.
# The distinct state freezes their complete evidence; it grants no
# new matching credit.
if ordinary_percentages.get(name) == 100.0 and not exact:
if relocation_shape_matches(target_info, base_info):
state = "ORDINARY_ACCEPTED_STRICT_MISMATCH"
accepted = True
else:
state = "ORDINARY_REJECTED_STRICT_MISMATCH"
accepted = False
if exception is not None and exception["entry"].get("owner_function"):
owner = exception["entry"]["owner_function"]
try:
_verify_local_label_continuation(target_obj, base_obj, name, owner)
except Exception as error: # semantic_progress raises its own strict error
raise GateError(
f"semantic exception no longer proves {unit_name}:{name}: {error}"
) from error
state = "SEMANTIC_EXACT"
accepted = True
exception_identity = exception["identity"]
elif exception is not None:
# Ordinary semantic-match entries still rely on the strict COFF
# comparator; the ledger only corrects objdiff's presentation.
if state != "STRICT_EXACT":
raise GateError(
f"semantic match is not strict-exact: {unit_name}:{name}"
)
exception_identity = exception["identity"]
comparisons[name] = {
"state": state,
"accepted": accepted,
"meaningful_size": int(target_fingerprint["meaningful_size"]),
"ordinary_percent": ordinary_percentages.get(name),
"exception_identity": exception_identity,
}
section_comparisons: Dict[str, Any] = {}
all_section_ids = sorted(
set(target["non_code_sections"]) | set(base["non_code_sections"])
)
semantic_sections: Dict[str, str] = {}
for symbol_name, exception in data_exceptions.items():
target_identity = _find_section_for_symbol(
target_obj, symbol_name, target["non_code_sections"]
)
base_identity = _find_section_for_symbol(
base_obj, symbol_name, base["non_code_sections"]
)
if target_identity is None or base_identity is None:
raise GateError(
f"cannot locate semantic data section {unit_name}:{symbol_name}"
)
try:
target_info = section_info_resolved(
target_obj, symbol_name, image_addresses
)
base_info = section_info_resolved(base_obj, symbol_name, image_addresses)
except CoffError as error:
raise GateError(
f"cannot verify semantic data section {unit_name}:{symbol_name}: {error}"
) from error
if target_info != base_info:
raise GateError(
f"semantic data section is no longer exact: {unit_name}:{symbol_name}"
)
expected = exception["entry"].get("measurements", {})
measured = {
key: target_info[key]
for key in ("size", "relocation_count", "normalized_sha256")
}
if expected != measured:
raise GateError(
f"semantic data target snapshot changed: {unit_name}:{symbol_name}"
)
semantic_sections[target_identity] = exception["identity"]
if target_identity != base_identity:
# The exception proves relocation aliases, not arbitrary section
# identity changes. Pairing different identities would hide the
# ownership regression this gate exists to detect.
raise GateError(
f"semantic data ownership identity differs for "
f"{unit_name}:{symbol_name}"
)
for identity in all_section_ids:
target_fingerprint = target["non_code_sections"].get(identity)
base_fingerprint = base["non_code_sections"].get(identity)
state = "TARGET_ONLY" if base_fingerprint is None else "BASE_ONLY"
accepted = False
exception_identity = semantic_sections.get(identity)
if target_fingerprint is not None and base_fingerprint is not None:
# Section numbers are object-local. Ownership identities, flags,
# symbols, bytes, and relocations remain strict.
strict_target = _section_acceptance_view(target_fingerprint)
strict_base = _section_acceptance_view(base_fingerprint)
if strict_target == strict_base:
state = "STRICT_EXACT"
accepted = True
elif exception_identity:
state = "SEMANTIC_EXACT"
accepted = True
else:
state = "NONEXACT"
section_comparisons[identity] = {
"state": state,
"accepted": accepted,
"exception_identity": exception_identity,
}
return {
"name": unit_name,
"config": {
"target_path": unit_config["target_path"],
"base_path": unit_config["base_path"],
},
"target": target,
"base": base,
"functions": comparisons,
"sections": section_comparisons,
}
def _semantic_entries(root: Path, path: Path) -> Sequence[Mapping[str, Any]]:
value = _read_json(_resolve_path(root, path))
if not isinstance(value, list):
raise GateError(f"semantic ledger must contain a JSON list: {path}")
return value
def _unit_configs(config: Mapping[str, Any]) -> Dict[str, Mapping[str, Any]]:
result: Dict[str, Mapping[str, Any]] = {}
for item in config.get("units", []):
name = item.get("name")
if name in result:
raise GateError(f"duplicate objdiff unit {name!r}")
if item.get("target_path") and item.get("base_path"):
result[name] = item
return result
def _select_units(
available: Mapping[str, Any], requested: Sequence[str], select_all: bool
) -> List[str]:
if bool(requested) == bool(select_all):
raise GateError("select exactly one of --unit (repeatable) or --all")
selected = sorted(available) if select_all else list(dict.fromkeys(requested))
missing = [name for name in selected if name not in available]
if missing:
raise GateError("unknown objdiff unit(s): " + ", ".join(missing))
return selected
def _logical_ninja_lines(text: str) -> List[str]:
result: List[str] = []
current = ""
for raw_line in text.splitlines():
stripped = raw_line.rstrip()
continuation = stripped.endswith("$")
if continuation:
stripped = stripped[:-1]
current += stripped.strip() if not current else " " + stripped.strip()
if not continuation:
if current:
result.append(current)
current = ""
if current:
result.append(current)
return result
def _build_environment(
root: Path, build_ninja: Path, units: Mapping[str, Mapping[str, Any]]
) -> Dict[str, Any]:
path = _resolve_path(root, build_ninja)
try:
logical_lines = _logical_ninja_lines(path.read_text(encoding="utf-8"))
except FileNotFoundError as error:
raise GateError(f"generated Ninja file is missing: {path}") from error
rule_command = None
for index, line in enumerate(logical_lines):
if line == "rule cl":
for candidate in logical_lines[index + 1 : index + 5]:
if candidate.startswith("command = "):
rule_command = candidate[len("command = ") :]
break
break
if not rule_command:
raise GateError("cannot locate the cl rule in build.ninja")
compiler_token = rule_command.split()[0]
compiler_path = _resolve_path(root, Path(compiler_token.replace("/", os.sep)))
if not compiler_path.is_file():
raise GateError(f"compiler from build.ninja is missing: {compiler_path}")
version_process = subprocess.run(
[str(compiler_path)],
cwd=root,
capture_output=True,
text=True,
timeout=20,
check=False,
)
version = (version_process.stdout + version_process.stderr).strip()
if not version:
raise GateError(f"compiler produced no version output: {compiler_path}")
flags_by_unit: Dict[str, str] = {}
for unit_name, unit in units.items():
base_path = str(unit["base_path"]).replace("/", "\\")
build_prefix = f"build {base_path}: cl "
matches = [
index
for index, line in enumerate(logical_lines)
if line.startswith(build_prefix)
]
if len(matches) != 1:
raise GateError(
f"expected one Ninja build edge for {unit_name}, found {len(matches)}"
)
flags = None
for candidate in logical_lines[matches[0] + 1 : matches[0] + 5]:
if candidate.startswith("cflags = "):
flags = candidate[len("cflags = ") :]
break
if flags is None:
raise GateError(f"cannot locate cflags for {unit_name}")
flags_by_unit[unit_name] = flags
return {
"rule_command": rule_command,
"compiler_path": compiler_token,
"compiler_sha256": _file_sha256(compiler_path),
"compiler_version": version,
"flags_by_unit": flags_by_unit,
}
def _build_units(
root: Path,
ninja: str,
unit_configs: Sequence[Mapping[str, Any]],
report_target: Optional[str] = None,
) -> None:
targets = [str(item["base_path"]) for item in unit_configs]
if report_target:
targets.append(report_target)
process = subprocess.run(
[ninja, *targets], cwd=root, text=True, capture_output=True, check=False
)
if process.returncode:
detail = (process.stdout + process.stderr)[-4000:]
raise GateError(f"Ninja object build failed:\n{detail}")
def create_manifest(
root: Path,
commit: str,
selected_units: Sequence[str],
config: Mapping[str, Any],
report: Mapping[str, Any],
semantic_matches: Sequence[Mapping[str, Any]],
semantic_data_matches: Sequence[Mapping[str, Any]],
image_symbol_entries: Sequence[Mapping[str, Any]],
build_environment: Mapping[str, Any],
) -> Dict[str, Any]:
available = _unit_configs(config)
exceptions = _exception_records(
selected_units, semantic_matches, semantic_data_matches
)
addresses = _symbol_addresses(image_symbol_entries)
units: Dict[str, Any] = {}
for unit_name in selected_units:
meaningful_sizes = _report_meaningful_sizes(report, unit_name)
units[unit_name] = _capture_unit(
root,
available[unit_name],
meaningful_sizes,
addresses,
exceptions,
_report_ordinary_percentages(report, unit_name),
)
return {
"schema_version": SCHEMA_VERSION,
"commit": commit,
"generated_utc": _datetime.datetime.now(
_datetime.timezone.utc
).isoformat(),
"config_sha256": _json_hash(config),
"build_environment": copy.deepcopy(build_environment),
"semantic_exceptions": exceptions,
"units": units,
}
def _diff_maps(
baseline: Mapping[str, Any], current: Mapping[str, Any]
) -> Tuple[List[str], List[str], List[str]]:
vanished = sorted(set(baseline) - set(current))
appeared = sorted(set(current) - set(baseline))
changed = sorted(
key for key in set(baseline) & set(current) if baseline[key] != current[key]
)
return vanished, appeared, changed
def _function_code_evidence(fingerprint: Mapping[str, Any]) -> Dict[str, Any]:
"""Return the runtime-relevant evidence an adjudication may never waive."""
return {
"padded_size": fingerprint.get("padded_size"),
"normalized_sha256": fingerprint.get("normalized_sha256"),
"relocation_count": fingerprint.get("relocation_count"),
"relocations": [
{
"address": item.get("address"),
"type": item.get("type"),
"addend": item.get("addend"),
"resolved_destination": copy.deepcopy(
item.get("resolved_destination")
),
"symbolic_destination": copy.deepcopy(
item.get("symbolic_destination")
),
}
for item in fingerprint.get("relocations", [])
],
}
def _comdat_delta_only(
before: Mapping[str, Any],
after: Mapping[str, Any],
entry: Mapping[str, Any],
) -> bool:
"""Prove that a wrapper changed only its recorded COMDAT selection.
The linked XBE cannot retain source-object COMDAT selection. This helper
is intentionally recipe-specific and does not create a general metadata
ignore rule.
"""
if entry.get("recipe") != XDK_D3DINLINE_RECIPE:
return False
if before.get("kind") != "CODE" or after.get("kind") != "CODE":
return False
if not before.get("comdat") or not after.get("comdat"):
return False
if before.get("comdat_selection") != entry.get("before_comdat_selection"):
return False
if after.get("comdat_selection") != entry.get("after_comdat_selection"):
return False
if entry.get("before_comdat_selection") != 1:
return False
if entry.get("after_comdat_selection") != 2:
return False
normalized_before = copy.deepcopy(before)
normalized_after = copy.deepcopy(after)
normalized_before["comdat_selection"] = "ADJUDICATED"
normalized_after["comdat_selection"] = "ADJUDICATED"
return normalized_before == normalized_after
def _index_adjudications(
value: Optional[Mapping[str, Any]],
) -> Tuple[Dict[Tuple[str, str], Mapping[str, Any]], Dict[Tuple[str, str], Mapping[str, Any]]]:
if value is None:
return {}, {}
if value.get("schema_version") != ADJUDICATION_SCHEMA_VERSION:
raise GateError("unsupported or missing adjudication schema version")
function_entries: Dict[Tuple[str, str], Mapping[str, Any]] = {}
debug_entries: Dict[Tuple[str, str], Mapping[str, Any]] = {}
for entry in value.get("functions", []):
key = (entry.get("unit"), entry.get("function"))
if not all(isinstance(item, str) and item for item in key):
raise GateError("function adjudication requires unit and function")
if key in function_entries:
raise GateError(f"duplicate function adjudication: {key[0]}:{key[1]}")
function_entries[key] = entry
for entry in value.get("debug_sections", []):
key = (entry.get("unit"), entry.get("section"))
if not all(isinstance(item, str) and item for item in key):
raise GateError("debug adjudication requires unit and section")
if key in debug_entries:
raise GateError(f"duplicate debug adjudication: {key[0]}:{key[1]}")
debug_entries[key] = entry
return function_entries, debug_entries
def _adjudicate_function_change(
unit_name: str,
function_name: str,
before_target: Mapping[str, Any],
after_target: Mapping[str, Any],
before_base: Mapping[str, Any],
after_base: Mapping[str, Any],
entry: Optional[Mapping[str, Any]],
) -> bool:
if entry is None:
return False
if entry.get("source_recipe") != "stock XDK D3DINLINE (static __forceinline)":
return False
if entry.get("before_fingerprint_sha256") != _json_hash(before_base):
return False
if entry.get("after_fingerprint_sha256") != _json_hash(after_base):
return False
if entry.get("target_evidence_sha256") != _json_hash(
_function_code_evidence(after_target)
):
return False
if before_target != after_target:
return False
if _function_code_evidence(before_base) != _function_code_evidence(after_base):
return False
if not _comdat_delta_only(before_base, after_base, entry):
return False
return True
def _adjudicate_debug_change(
before: Mapping[str, Any],
after: Mapping[str, Any],
entry: Optional[Mapping[str, Any]],
) -> bool:
"""Permit one exact, pre-recorded compiler-debug transition only."""
if entry is None or entry.get("recipe") != XDK_D3DINLINE_RECIPE:
return False
if not str(before.get("name", "")).startswith(".debug"):
return False
if not str(after.get("name", "")).startswith(".debug"):
return False
return (
entry.get("before_fingerprint_sha256") == _json_hash(before)
and entry.get("after_fingerprint_sha256") == _json_hash(after)
)
def compare_manifests(
baseline: Mapping[str, Any],
current: Mapping[str, Any],
adjudications: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
result: Dict[str, Any] = {
"ok": True,
"failures": [],
"warnings": [],
"units": {},
}
def fail(kind: str, message: str, **detail: Any) -> None:
result["ok"] = False
record = {"kind": kind, "message": message}
record.update(detail)
result["failures"].append(record)
try:
function_adjudications, debug_adjudications = _index_adjudications(
adjudications
)
except GateError as error:
fail("UNKNOWN", str(error))
return result
consumed_functions = set()
consumed_debug_sections = set()
if baseline.get("schema_version") != SCHEMA_VERSION:
fail("UNKNOWN", "unsupported or missing baseline schema version")
return result
if baseline.get("build_environment") != current.get("build_environment"):
fail("UNKNOWN", "compiler identity or per-unit flags changed")
if baseline.get("config_sha256") != current.get("config_sha256"):
fail("UNKNOWN", "objdiff configuration changed")
if baseline.get("semantic_exceptions") != current.get("semantic_exceptions"):
fail("UNKNOWN", "semantic-exception identities changed")
baseline_units = baseline.get("units", {})
current_units = current.get("units", {})
vanished_units, appeared_units, _ = _diff_maps(baseline_units, current_units)
for unit_name in vanished_units:
fail("UNKNOWN", f"baseline unit vanished: {unit_name}", unit=unit_name)
for unit_name in appeared_units:
fail("UNKNOWN", f"unexpected unit appeared: {unit_name}", unit=unit_name)
for unit_name in sorted(set(baseline_units) & set(current_units)):
before = baseline_units[unit_name]
after = current_units[unit_name]
unit_result = {
"still_exact": [],
"newly_exact": [],
"changed_nonexact": [],
}
result["units"][unit_name] = unit_result
if before["target"] != after["target"]:
fail(
"UNKNOWN",
f"target object evidence changed for {unit_name}",
unit=unit_name,
)
before_functions = before["functions"]
after_functions = after["functions"]
vanished, appeared, _ = _diff_maps(before_functions, after_functions)
for name in vanished:
fail(
"SYMBOL_SET_CHANGED",
f"target function vanished: {unit_name}:{name}",
unit=unit_name,
item=name,
)
for name in appeared:
fail(
"SYMBOL_SET_CHANGED",
f"target function appeared: {unit_name}:{name}",
unit=unit_name,
item=name,
)
for name in sorted(set(before_functions) & set(after_functions)):
previous = before_functions[name]
now = after_functions[name]
previous_base_fingerprint = before["base"]["functions"].get(name)
current_base_fingerprint = after["base"]["functions"].get(name)
if previous["accepted"]:
if not now["accepted"]:
fail(
"REGRESSED",
f"accepted function regressed: {unit_name}:{name}",
unit=unit_name,
item=name,
before=previous["state"],
after=now["state"],
)
elif (
previous != now
or previous_base_fingerprint != current_base_fingerprint
):
key = (unit_name, name)
entry = function_adjudications.get(key)
if _adjudicate_function_change(
unit_name,
name,
before["target"]["functions"].get(name, {}),
after["target"]["functions"].get(name, {}),
previous_base_fingerprint or {},
current_base_fingerprint or {},
entry,
):
consumed_functions.add(key)
unit_result.setdefault("adjudicated_exact", []).append(name)
result["warnings"].append(
{
"kind": "ADJUDICATED_COMDAT_CHANGE",
"unit": unit_name,
"item": name,
"message": (
"exact code and relocation evidence preserved; "
"reviewed stock-XDK COMDAT transition accepted"
),
}
)
else:
fail(
"UNKNOWN",
f"accepted function evidence changed: {unit_name}:{name}",
unit=unit_name,
item=name,
)
else:
unit_result["still_exact"].append(name)
elif now["accepted"]:
unit_result["newly_exact"].append(name)
result["warnings"].append(
{
"kind": "NEWLY_EXACT",
"unit": unit_name,
"item": name,
"message": "review required; no matching credit was granted",
}
)
elif (
previous != now
or previous_base_fingerprint != current_base_fingerprint
):
unit_result["changed_nonexact"].append(name)
# Freeze every non-code section in the rebuilt object, even in an
# incomplete unit. This is what catches a static BSS becoming a COMMON
# symbol while all functions remain byte-identical.
before_sections = before["base"]["non_code_sections"]
after_sections = after["base"]["non_code_sections"]
vanished, appeared, changed = _diff_maps(before_sections, after_sections)
for identity in vanished + appeared:
fail(
"DATA_CHANGED",
f"rebuilt non-code section changed: {unit_name}:{identity}",
unit=unit_name,
item=identity,
)
for identity in changed:
key = (unit_name, identity)
if _adjudicate_debug_change(
before_sections[identity],
after_sections[identity],
debug_adjudications.get(key),
):
consumed_debug_sections.add(key)
result["warnings"].append(
{
"kind": "ADJUDICATED_DEBUG_CHANGE",
"unit": unit_name,
"item": identity,
"message": (
"exact pre-recorded stock-XDK compiler-debug "
"transition accepted"
),
}
)
else:
fail(
"DATA_CHANGED",
f"rebuilt non-code section changed: {unit_name}:{identity}",
unit=unit_name,
item=identity,
)
before_symbols = before["base"]["symbols"]
after_symbols = after["base"]["symbols"]
if before_symbols != after_symbols:
fail(
"SYMBOL_SET_CHANGED",
f"rebuilt symbol ownership changed for {unit_name}",
unit=unit_name,
)
unused_functions = sorted(set(function_adjudications) - consumed_functions)
unused_debug_sections = sorted(
set(debug_adjudications) - consumed_debug_sections
)
for unit_name, name in unused_functions:
fail(
"UNKNOWN",
f"function adjudication was not consumed: {unit_name}:{name}",
unit=unit_name,
item=name,
)
for unit_name, identity in unused_debug_sections:
fail(
"UNKNOWN",
f"debug adjudication was not consumed: {unit_name}:{identity}",
unit=unit_name,
item=identity,
)
return result
def load_baseline(path: Path) -> Mapping[str, Any]:
if not path.is_file():
raise GateError(f"regression baseline is missing: {path}")
value = _read_json(path)
if value.get("schema_version") != SCHEMA_VERSION:
raise GateError(
f"unsupported regression baseline schema: {value.get('schema_version')!r}"
)
if not value.get("commit"):
raise GateError("regression baseline does not record a clean commit")
return value
def _write_json(path: Path, value: Any) -> None:
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(value, indent=2, sort_keys=True) + "\n", encoding="utf-8")
def _common_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(add_help=False)
parser.add_argument("--root", type=Path, default=Path.cwd())
parser.add_argument("--manifest", type=Path, default=Path("build/regression_manifest.json"))
parser.add_argument("--config", type=Path, default=Path("objdiff.json"))
parser.add_argument("--report", type=Path, default=Path("build/report.json"))
parser.add_argument("--semantic-matches", type=Path, default=Path("config/semantic_matches.json"))
parser.add_argument("--semantic-data-matches", type=Path, default=Path("config/semantic_data_matches.json"))
parser.add_argument("--symbols", type=Path, default=Path("config/symbols.json"))
parser.add_argument("--build-ninja", type=Path, default=Path("build.ninja"))
parser.add_argument("--ninja", default="ninja")
parser.add_argument("--unit", action="append", default=[])
parser.add_argument("--all", action="store_true")
parser.add_argument("--no-build", action="store_true", help=argparse.SUPPRESS)
return parser
def _capture_from_cli(
args: argparse.Namespace,
selected: Sequence[str],
config: Mapping[str, Any],
baseline: Optional[Mapping[str, Any]] = None,
) -> Dict[str, Any]:
root = args.root.resolve()
available = _unit_configs(config)
selected_configs = {name: available[name] for name in selected}
if not args.no_build:
_build_units(
root,
args.ninja,
list(selected_configs.values()),
str(args.report),
)
report = _read_json(_resolve_path(root, args.report))
semantic_matches = _semantic_entries(root, args.semantic_matches)
semantic_data_matches = _semantic_entries(root, args.semantic_data_matches)
symbol_entries = _read_json(_resolve_path(root, args.symbols))
build_environment = _build_environment(root, args.build_ninja, selected_configs)
commit = _git(root, "rev-parse", "HEAD")
return create_manifest(
root,
commit,
selected,
config,
report,
semantic_matches,
semantic_data_matches,
symbol_entries,
build_environment,
)
def main(argv: Optional[Sequence[str]] = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
subparsers = parser.add_subparsers(dest="command", required=True)
snapshot_parser = subparsers.add_parser("snapshot", parents=[_common_parser()])
check_parser = subparsers.add_parser("check", parents=[_common_parser()])
check_parser.add_argument(
"--adjudications",
type=Path,
help=(
"explicit one-shot reviewed evidence changes; omitted by default "
"so ordinary checks remain fully strict"
),
)
explain_parser = subparsers.add_parser("explain")
explain_parser.add_argument("unit")
explain_parser.add_argument("item")
explain_parser.add_argument("--manifest", type=Path, default=Path("build/regression_manifest.json"))
args = parser.parse_args(argv)
try:
if args.command == "explain":
baseline = load_baseline(args.manifest)
unit = baseline.get("units", {}).get(args.unit)
if unit is None:
raise GateError(f"unit not present in baseline: {args.unit}")
if args.item in unit["functions"]:
payload = {
"kind": "function",
"comparison": unit["functions"][args.item],
"target": unit["target"]["functions"].get(args.item),
"base": unit["base"]["functions"].get(args.item),
}
elif args.item in unit["sections"]:
payload = {
"kind": "section",
"comparison": unit["sections"][args.item],
"target": unit["target"]["non_code_sections"].get(args.item),
"base": unit["base"]["non_code_sections"].get(args.item),
}
else:
raise GateError(
f"item not present in baseline: {args.unit}:{args.item}"
)
print(json.dumps(payload, indent=2, sort_keys=True))
return 0
root = args.root.resolve()
config = _read_json(_resolve_path(root, args.config))
available = _unit_configs(config)
if args.command == "snapshot":
selected = _select_units(available, args.unit, args.all)
commit = require_clean_commit(root)
manifest = _capture_from_cli(args, selected, config)
manifest["commit"] = commit
output = _resolve_path(root, args.manifest)
_write_json(output, manifest)
print(
json.dumps(
{
"status": "SNAPSHOT_WRITTEN",
"manifest": str(output),
"commit": commit,
"units": selected,
},
indent=2,
)
)
return 0
baseline_path = _resolve_path(root, args.manifest)
baseline = load_baseline(baseline_path)
baseline_names = sorted(baseline["units"])
if args.unit or args.all:
selected = _select_units(available, args.unit, args.all)
if set(selected) != set(baseline_names):
raise GateError(
"check selection must exactly match the baseline unit set"
)
else:
selected = baseline_names
current = _capture_from_cli(args, selected, config, baseline)
adjudications = None
if args.adjudications is not None:
adjudications = _read_json(
_resolve_path(root, args.adjudications)
)
comparison = compare_manifests(
baseline, current, adjudications=adjudications
)
print(json.dumps(comparison, indent=2, sort_keys=True))
return 0 if comparison["ok"] else 1
except GateError as error:
print(f"regression gate error: {error}", file=sys.stderr)
return 2
if __name__ == "__main__":
raise SystemExit(main())