import copy import hashlib import re from dataclasses import dataclass from viano_bug_analysis.lifecycle.checkpoints import checkpoint_chain from viano_bug_analysis.lifecycle.storage import json_bytes from viano_bug_analysis.package.validator import CHUNK_SIZE from .contracts import read_json, require_file, validate from .errors import CaseBuildError from .expected_behavior import collect_expected_behavior from .report_collector import collect_statements, provenance from .reproduction_collector import collect_reproductions def _stable_records(previous: list[dict], current: list[dict], prefix: str) -> list[dict]: def identity(record): source = record["provenance"] return source["source_ref"], source["json_pointer"] remaining = {identity(record): record for record in current} result = [] ids = set() for old in previous: if not re.fullmatch(prefix + r"[0-9]{4,}", old["id"]) or old["id"] in ids: raise CaseBuildError("PREVIOUS_IDS", "Prior case contains invalid or duplicate record IDs") ids.add(old["id"]) record = remaining.pop(identity(old), None) if record is None: raise CaseBuildError("PREVIOUS_RECORD_LOST", "Prior source record disappeared from its structured input") record["id"] = old["id"] result.append(record) next_id = max((int(value[len(prefix):]) for value in ids), default=0) + 1 for record in current: if identity(record) in remaining: record["id"] = f"{prefix}{next_id:04d}" next_id += 1 result.append(record) del remaining[identity(record)] return result @dataclass(frozen=True) class CaseBuilderLimits: """Operational limits, independent of the JSON format contracts.""" max_json_bytes: int = 1024**2 max_structured_bytes: int = 8 * 1024**2 max_documents: int = 256 max_case_bytes: int = 8 * 1024**2 max_summary_bytes: int = 8 * 1024**2 max_inline_fact_bytes: int = 16 * 1024 def __post_init__(self): for value in vars(self).values(): if isinstance(value, bool) or not isinstance(value, int) or value <= 0: raise ValueError("CaseBuilder limits must be positive integers") class CaseAssembler: def __init__(self, limits: CaseBuilderLimits | None = None): self.limits = limits or CaseBuilderLimits() def assemble(self, reader) -> dict: manifest = reader.manifest if manifest["analysis_revision"] < 1: raise CaseBuildError("INVALID_REVISION", "Case construction requires an active analysis revision") used_bytes = 0 documents = 0 def load(path, contract): nonlocal used_bytes, documents require_file(reader, path) if contract == "case": return read_json(reader, path, contract, self.limits.max_case_bytes) documents += 1 used_bytes += reader._archive.getinfo(path).file_size if documents > self.limits.max_documents or used_bytes > self.limits.max_structured_bytes: raise CaseBuildError("STRUCTURED_INPUT_LIMIT", "Declared structured inputs exceed configured limits", path) return read_json(reader, path, contract, self.limits.max_json_bytes) chain = checkpoint_chain(reader) excluded = {"manifest.json"} | {ref for ref, _ in chain} prior_refs = [] for _, checkpoint in reversed(chain): details = checkpoint["details"] if details.get("operation") == "case-built" and details.get("builder_version") == "1": for field in ("case_ref", "summary_ref"): if not isinstance(details.get(field), str): raise CaseBuildError("PREVIOUS_CASE", "Incomplete case completion checkpoint") require_file(reader, details[field]) excluded.add(details[field]) if details.get("case_ref") not in prior_refs: prior_refs.append(details["case_ref"]) prior_ref = manifest["artifacts"].get("case") if prior_ref: excluded.add(prior_ref) if prior_ref not in prior_refs: prior_refs.append(prior_ref) summary_ref = manifest["artifacts"].get("case_summary") if summary_ref: excluded.add(summary_ref) previous = load(prior_ref, "case") if prior_ref else None if previous and (previous["case"]["case_id"] != manifest["case_id"] or previous["case"]["package_id"] != manifest["package_id"] or previous["case"]["analysis_revision"] > manifest["analysis_revision"]): raise CaseBuildError("CASE_IDENTITY", "Previous case has inconsistent identity or revision", prior_ref) inventory = {} for name in sorted(reader.files): if name == "manifest.json" or name in {ref for ref, _ in chain}: continue digest = hashlib.sha256() size = 0 with reader._archive.open(name) as stream: while chunk := stream.read(CHUNK_SIZE): size += len(chunk) if size > reader.validator.limits.max_file_bytes: raise CaseBuildError("INPUT_LIMIT", "Payload exceeds configured file limit", name) digest.update(chunk) inventory[name] = {"path": name, "sha256": digest.hexdigest(), "size": size} index_ref = manifest["artifacts"].get("case_inputs") index = load(index_ref, "case-inputs") if index_ref else {} roles = [] def add(path, role): require_file(reader, path) if path in excluded: raise CaseBuildError("INPUT_REFERENCE", "Generated case or checkpoint cannot serve as raw input", path) if (path, role) not in roles: roles.append((path, role)) if previous: for record in previous["inputs"]["records"]: add(record["path"], record["role"]) actual = inventory[record["path"]] if record["sha256"] != actual["sha256"] or record["size"] != actual["size"]: raise CaseBuildError("PREVIOUS_INPUT_CHANGED", "A source referenced by the prior case changed", record["path"]) if index_ref: add(index_ref, "index") for field, role in (("reports", "report"), ("reproductions", "reproduction"), ("attachments", "attachment")): paths = index.get(field, []) if len(paths) != len(set(paths)): raise CaseBuildError("INPUT_DUPLICATE", "A source is listed twice in the same role", index_ref) for path in paths: add(path, role) for role, paths in index.get("context", {}).items(): if role not in {"runtime", "database", "source", "research"}: continue if len(paths) != len(set(paths)): raise CaseBuildError("INPUT_DUPLICATE", "A context source is listed twice", index_ref) for path in paths: add(path, role) reports = [(path, load(path, "report")) for path, role in roles if role == "report"] reproductions = [(path, load(path, "reproduction")) for path, role in roles if role == "reproduction"] for _, document in reports + reproductions: nodes = [document] + [node for field in ("observations", "expected_behavior", "comments", "tester_hypotheses", "facts", "entities", "data_quality_issues") for node in document.get(field, [])] for node in nodes: if "origin" in node: for field in ("source_ref", "archive_ref"): if node["origin"].get(field) is not None: require_file(reader, node["origin"][field]) for _, document in reproductions: for path in document.get("attachments", []): add(path, "attachment") if document.get("events_ref"): add(document["events_ref"], "attachment") observations = collect_statements(reports + reproductions, "observations", "O") comments = collect_statements(reports, "comments", "C") hypotheses = collect_statements(reports, "tester_hypotheses", "T") expected = collect_expected_behavior(reports) reproduction_records, facts = collect_reproductions(reproductions, self.limits.max_inline_fact_bytes) if previous: old_humans = previous["human_observations"] observations = _stable_records(old_humans["observations"], observations, "O") comments = _stable_records(old_humans["comments"], comments, "C") hypotheses = _stable_records(old_humans["tester_hypotheses"], hypotheses, "T") expected["statements"] = _stable_records(previous["expected_behavior"]["statements"], expected["statements"], "E") facts = _stable_records(previous["runtime_facts"]["facts"], facts, "F") entities = {} for path, document in reports + reproductions: for index, entity in enumerate(document.get("entities", [])): entity_id = entity["id"] if isinstance(entity["id"], str) else str(int(entity["id"])) identity = (entity["type"], entity_id) record = entities.setdefault(identity, {"type": identity[0], "id": identity[1], "roles": [], "provenance": []}) record["roles"] = sorted(set(record["roles"]) | set(entity.get("roles", []))) record["provenance"].append(provenance(path, f"/entities/{index}", "report" if document["format"] == "viano-bug-report" else "reproduction_record", entity)) issues = [] def issue(code, message, refs=None): issues.append({"code": code, "message": message, "refs": refs or []}) for path, report in reports: for index, item in enumerate(report.get("data_quality_issues", [])): source = item.get("origin", {"source_ref": path, "json_pointer": f"/data_quality_issues/{index}"}) issue(item["code"], item["message"], [{"source_ref": source["source_ref"], "json_pointer": source["json_pointer"]}]) if not index_ref: issue("MISSING_INPUT_INDEX", "No explicit case input index was supplied") if not reports: issue("MISSING_REPORT", "No structured report was supplied") if not observations: issue("MISSING_OBSERVATIONS", "No direct observations were supplied") if not reproductions: issue("MISSING_REPRODUCTION", "No structured reproduction was supplied") if expected["completeness"] != "reported_complete": issue("EXPECTED_BEHAVIOR_" + expected["completeness"].upper(), "Expected-behavior completeness is " + expected["completeness"], [{"source_ref": path, "json_pointer": "/expected_behavior_complete"} for path, report in reports if "expected_behavior_complete" in report]) for category, records in (("observation", observations), ("expected_behavior", expected["statements"])): claims = {} for record in records: if "claim" in record: claim = record["claim"] claims.setdefault(claim["key"], []).append(record) for claim_key, records in sorted(claims.items()): if len({json_bytes(record["claim"]["value"]) for record in records}) > 1: refs = [{"source_ref": r["provenance"]["source_ref"], "json_pointer": r["provenance"]["json_pointer"]} for r in records] issue("CONFLICTING_CLAIMS", f"Different explicit values for {category} claim {claim_key}", refs) if category == "expected_behavior": expected["completeness"] = "conflicting" records = [inventory[path] | {"role": role} for path, role in roles] classified = {path for path, _ in roles} raw_inventory = [value for name, value in inventory.items() if name not in excluded] request = {"builder_version": "1", "case_id": manifest["case_id"], "package_id": manifest["package_id"], "analysis_revision": int(manifest["analysis_revision"]), "project": manifest["project"], "index_ref": index_ref, "payloads": raw_inventory, "roles": roles} request_hash = hashlib.sha256(json_bytes(request)).hexdigest() refs_for = lambda role: [inventory[path] for path, value in roles if value == role] case = { "format": "viano-case", "format_version": 1, "builder_version": "1", "case": {"case_id": manifest["case_id"], "package_id": manifest["package_id"], "analysis_revision": int(manifest["analysis_revision"]), "source_generation": int(manifest["package_generation"]), "project": copy.deepcopy(manifest["project"])}, "bug": {"reports": [{"source_ref": path, "title": document.get("title"), "summary": document.get("summary"), **({"origin": copy.deepcopy(document["origin"])} if "origin" in document else {})} for path, document in reports]}, "human_observations": {"observations": observations, "comments": comments, "tester_hypotheses": hypotheses}, "expected_behavior": expected, "reproduction": {"records": reproduction_records}, "entities": [entities[identity] for identity in sorted(entities)], "runtime_facts": {"facts": facts, "raw_refs": refs_for("runtime")}, "database_context": {"refs": refs_for("database")}, "source_context": {"refs": refs_for("source")}, "research": {"refs": refs_for("research"), "performed": False}, "data_quality": {"issues": issues, "coverage": {"reports": len(reports), "reproductions": len(reproductions), "observations": len(observations)}}, "inputs": {"index_ref": index_ref, "records": records, "unclassified": [value for name, value in inventory.items() if name not in classified and name not in excluded], "prior_cases": [inventory[ref] for ref in prior_refs], "request_hash": request_hash}, } validate(case, "case") if len(json_bytes(case)) > self.limits.max_case_bytes: raise CaseBuildError("CASE_LIMIT", "Assembled case exceeds configured byte limit") return case