Add safe Vcore wowdiag and wowsrc imports with original-data provenance
This commit is contained in:
1 parent
4543e7cd31
commit
741c1ca4a5
25 files changed
+1469
-9
No files matched your search
@@ -150,6 +150,13 @@ class CaseAssembler:
|
||||
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")
|
||||
@@ -174,11 +181,15 @@ class CaseAssembler:
|
||||
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"))
|
||||
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")
|
||||
@@ -215,7 +226,8 @@ class CaseAssembler:
|
||||
"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")} for path, document in reports]},
|
||||
"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},
|
||||
|
||||
@@ -3,8 +3,11 @@ import copy
|
||||
|
||||
def provenance(path: str, pointer: str, kind: str, statement: dict | None = None) -> dict:
|
||||
value = statement or {}
|
||||
return {"kind": kind, "source_ref": path, "json_pointer": pointer,
|
||||
"author": value.get("author"), "observed_at": value.get("observed_at")}
|
||||
result = {"kind": kind, "source_ref": path, "json_pointer": pointer,
|
||||
"author": value.get("author"), "observed_at": value.get("observed_at")}
|
||||
if "origin" in value:
|
||||
result["origin"] = copy.deepcopy(value["origin"])
|
||||
return result
|
||||
|
||||
|
||||
def collect_statements(documents: list[tuple[str, dict]], field: str, prefix: str) -> list[dict]:
|
||||
|
||||
@@ -8,12 +8,15 @@ from .report_collector import provenance
|
||||
def collect_reproductions(documents: list[tuple[str, dict]], inline_limit: int) -> tuple[list[dict], list[dict]]:
|
||||
records, facts = [], []
|
||||
for path, document in documents:
|
||||
records.append({"id": f"R{len(records) + 1:04d}", "source_ref": path,
|
||||
record = {"id": f"R{len(records) + 1:04d}", "source_ref": path,
|
||||
"steps": list(document.get("steps", [])), "events_ref": document.get("events_ref"),
|
||||
"attachments": list(document.get("attachments", []))})
|
||||
"attachments": list(document.get("attachments", []))}
|
||||
if "origin" in document:
|
||||
record["origin"] = copy.deepcopy(document["origin"])
|
||||
records.append(record)
|
||||
for index, fact in enumerate(document.get("facts", [])):
|
||||
record = {"id": f"F{len(facts) + 1:04d}", "name": fact["name"],
|
||||
"provenance": provenance(path, f"/facts/{index}", "reproduction_record")}
|
||||
"provenance": provenance(path, f"/facts/{index}", "reproduction_record", fact)}
|
||||
if len(json_bytes(fact["value"])) > inline_limit:
|
||||
record["value_ref"] = {"source_ref": path, "json_pointer": f"/facts/{index}/value"}
|
||||
else:
|
||||
|
||||
@@ -11,6 +11,17 @@ def _literal(value) -> str:
|
||||
return text.replace("\r", "\\r").replace("\n", "<br>")
|
||||
|
||||
|
||||
def _origin_lines(origin: dict | None) -> list[str]:
|
||||
if origin is None:
|
||||
return []
|
||||
result = [f" Original source: {_literal(origin['source_ref'])} {_literal(origin['json_pointer'])}"]
|
||||
if "decoded_json_pointer" in origin:
|
||||
result.append(f" Decoded JSON pointer: {_literal(origin['decoded_json_pointer'])}")
|
||||
if "source_timestamp" in origin:
|
||||
result.append(f" Source timestamp (unchanged): {_literal(origin['source_timestamp'])}")
|
||||
return result
|
||||
|
||||
|
||||
def render_summary(case: dict) -> str:
|
||||
"""Pure presentation of the canonical case, with no additional interpretation."""
|
||||
validate(case, "case")
|
||||
@@ -22,6 +33,7 @@ def render_summary(case: dict) -> str:
|
||||
lines += [f"- Source: {_literal(report['source_ref'])}",
|
||||
f" Title: {_literal(report['title']) if report['title'] is not None else '(not supplied)'}",
|
||||
f" Summary: {_literal(report['summary']) if report['summary'] is not None else '(not supplied)'}"]
|
||||
lines.extend(_origin_lines(report.get("origin")))
|
||||
for label, records in (("Direct human observations", case["human_observations"]["observations"]),
|
||||
("Tester comments", case["human_observations"]["comments"]),
|
||||
("Tester hypotheses (supplied)", case["human_observations"]["tester_hypotheses"]),
|
||||
@@ -33,11 +45,13 @@ def render_summary(case: dict) -> str:
|
||||
f" Source: {_literal(source['source_ref'])} {_literal(source['json_pointer'])}",
|
||||
f" Author: {_literal(source['author']) if source['author'] is not None else '(not supplied)'}",
|
||||
f" Observed at: {_literal(source['observed_at']) if source['observed_at'] is not None else '(not supplied)'}"]
|
||||
lines.extend(_origin_lines(source.get("origin")))
|
||||
if "claim" in record:
|
||||
lines.append(f" Claim: {_literal(record['claim']['key'])} = {_literal(json.dumps(record['claim']['value'], sort_keys=True, ensure_ascii=False))}")
|
||||
lines += ["", f"Expected-behavior completeness: {case['expected_behavior']['completeness']}", "", "## Reproductions", ""]
|
||||
for record in case["reproduction"]["records"]:
|
||||
lines.append(f"- {_literal(record['id'])}: {_literal(record['source_ref'])}")
|
||||
lines.extend(_origin_lines(record.get("origin")))
|
||||
for step in record["steps"]:
|
||||
lines.append(f" Step: {_literal(step)}")
|
||||
if record["events_ref"]:
|
||||
@@ -49,6 +63,7 @@ def render_summary(case: dict) -> str:
|
||||
lines.append(f"- {_literal(entity['type'])} / {_literal(entity['id'])}; roles: {_literal(', '.join(entity['roles']))}")
|
||||
for source in entity["provenance"]:
|
||||
lines.append(f" Source: {_literal(source['source_ref'])} {_literal(source['json_pointer'])}")
|
||||
lines.extend(_origin_lines(source.get("origin")))
|
||||
lines += ["", "## Facts supplied by reproduction records", ""]
|
||||
for fact in case["runtime_facts"]["facts"]:
|
||||
source = fact["provenance"]
|
||||
@@ -59,6 +74,7 @@ def render_summary(case: dict) -> str:
|
||||
value = _literal(json.dumps(fact['value'], sort_keys=True, ensure_ascii=False))
|
||||
lines.append(f"- {_literal(fact['id'])}: {_literal(fact['name'])} = {value}")
|
||||
lines.append(f" Source: {_literal(source['source_ref'])} {_literal(source['json_pointer'])}")
|
||||
lines.extend(_origin_lines(source.get("origin")))
|
||||
for label, refs in (("Runtime data", case["runtime_facts"]["raw_refs"]), ("Database context", case["database_context"]["refs"]),
|
||||
("Source context", case["source_context"]["refs"]), ("Supplied research", case["research"]["refs"]),
|
||||
("Prior cases", case["inputs"]["prior_cases"]), ("Unclassified inputs", case["inputs"]["unclassified"])):
|
||||
|
||||
@@ -3,6 +3,7 @@ from .limits import PackageLimits
|
||||
from .reader import PackageReader
|
||||
from .validator import PackageValidator
|
||||
from .writer import PackageWriter
|
||||
from .factory import PackageFactory
|
||||
|
||||
__all__ = ["PackageReader", "PackageValidator", "PackageLimits", "PackageReadError",
|
||||
"ValidationIssue", "ValidationResult", "PackageWriter", "PackageWriteError"]
|
||||
"ValidationIssue", "ValidationResult", "PackageWriter", "PackageWriteError", "PackageFactory"]
|
||||
@@ -0,0 +1,66 @@
|
||||
import copy
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import tempfile
|
||||
import warnings
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
|
||||
from .errors import PackageWriteError
|
||||
from .manifest import safe_path
|
||||
from .validator import ARCHIVE_ERRORS, PackageValidator
|
||||
|
||||
|
||||
class PackageFactory:
|
||||
"""Create an initial v1 package from caller-owned identity and supplied bytes."""
|
||||
|
||||
def __init__(self, limits=None):
|
||||
self.validator = PackageValidator(limits)
|
||||
|
||||
def create(self, destination, manifest: dict, artifacts: dict[str, bytes]):
|
||||
temporary = None
|
||||
published = False
|
||||
target = Path(destination)
|
||||
try:
|
||||
if target.exists() or target.is_symlink():
|
||||
raise PackageWriteError(self.validator._failure("TARGET_EXISTS", "Initial package target already exists"))
|
||||
document = copy.deepcopy(manifest)
|
||||
if document.get("package_generation") != 0:
|
||||
raise PackageWriteError(self.validator._failure("INITIAL_GENERATION", "Initial generation must be zero"))
|
||||
for path, data in artifacts.items():
|
||||
if not safe_path(path) or path == "manifest.json" or not isinstance(data, bytes):
|
||||
raise PackageWriteError(self.validator._failure("ARTIFACT_INVALID", "Unsafe path or non-byte artifact", path))
|
||||
if len(data) > self.validator.limits.max_file_bytes:
|
||||
raise PackageWriteError(self.validator._failure("FILE_LIMIT", "Initial artifact exceeds configured limit", path))
|
||||
document.setdefault("integrity", {}).setdefault(path, {}).update(size=len(data), sha256=hashlib.sha256(data).hexdigest())
|
||||
data = json.dumps(document, sort_keys=True, ensure_ascii=False, allow_nan=False).encode("utf-8")
|
||||
if len(data) > min(self.validator.limits.max_manifest_bytes, self.validator.limits.max_file_bytes):
|
||||
raise PackageWriteError(self.validator._failure("FILE_LIMIT", "Initial manifest exceeds configured limit"))
|
||||
if len(artifacts) + 1 > self.validator.limits.max_entries or sum(map(len, artifacts.values())) + len(data) > min(self.validator.limits.max_total_bytes, self.validator.limits.max_archive_bytes):
|
||||
raise PackageWriteError(self.validator._failure("PACKAGE_LIMIT", "Initial package exceeds configured limits"))
|
||||
with tempfile.NamedTemporaryFile(dir=target.parent, prefix=".vbcase-", suffix=".tmp", delete=False) as stream:
|
||||
temporary = Path(stream.name)
|
||||
with zipfile.ZipFile(stream, "w", compression=zipfile.ZIP_STORED) as archive:
|
||||
for path, payload in artifacts.items():
|
||||
archive.writestr(path, payload)
|
||||
archive.writestr("manifest.json", data)
|
||||
stream.flush()
|
||||
os.fsync(stream.fileno())
|
||||
result = self.validator.validate(temporary)
|
||||
if not result.valid:
|
||||
raise PackageWriteError(result)
|
||||
os.link(temporary, target)
|
||||
published = True
|
||||
return result
|
||||
except ARCHIVE_ERRORS + (TypeError, KeyError, RecursionError, AttributeError) as error:
|
||||
raise PackageWriteError(self.validator._failure("CREATE_ERROR", str(error))) from error
|
||||
finally:
|
||||
if temporary is not None:
|
||||
try:
|
||||
temporary.unlink(missing_ok=True)
|
||||
except OSError as error:
|
||||
if published:
|
||||
warnings.warn(f"Package created but staging cleanup failed: {error}", ResourceWarning)
|
||||
else:
|
||||
raise PackageWriteError(self.validator._failure("CLEANUP_ERROR", str(error))) from error
|
||||
@@ -0,0 +1 @@
|
||||
"""Project-specific adapters, separate from deterministic package/case foundations."""
|
||||
@@ -0,0 +1,7 @@
|
||||
from .archive_reader import ArchiveLimits, ArchiveReader
|
||||
from .errors import AdapterError
|
||||
from .importer import VcoreImporter, ImportPlan
|
||||
from .wowdiag_adapter import WowdiagAdapter
|
||||
from .wowsrc_adapter import WowsrcAdapter
|
||||
|
||||
__all__ = ["ArchiveLimits", "ArchiveReader", "AdapterError", "VcoreImporter", "ImportPlan", "WowdiagAdapter", "WowsrcAdapter"]
|
||||
@@ -0,0 +1,160 @@
|
||||
import gzip
|
||||
import io
|
||||
import os
|
||||
import tarfile
|
||||
import tempfile
|
||||
import zlib
|
||||
from dataclasses import dataclass
|
||||
|
||||
from viano_bug_analysis.package.manifest import parse_manifest, safe_path
|
||||
from viano_bug_analysis.package.validator import CHUNK_SIZE
|
||||
|
||||
from .errors import AdapterError
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ArchiveLimits:
|
||||
max_archive_bytes: int = 128 * 1024**2
|
||||
max_tar_bytes: int = 256 * 1024**2
|
||||
max_file_bytes: int = 64 * 1024**2
|
||||
max_entries: int = 10_000
|
||||
max_json_bytes: int = 4 * 1024**2
|
||||
max_header_bytes: int = 64 * 1024
|
||||
max_ratio: int = 200
|
||||
max_import_bytes: int = 128 * 1024**2
|
||||
|
||||
def __post_init__(self):
|
||||
if any(isinstance(value, bool) or not isinstance(value, int) or value <= 0 for value in vars(self).values()):
|
||||
raise ValueError("Archive limits must be positive integers")
|
||||
|
||||
|
||||
class ArchiveReader:
|
||||
"""Validate gzip fully and read TAR members without filesystem extraction."""
|
||||
|
||||
def __init__(self, path, limits: ArchiveLimits | None = None):
|
||||
self.path = path
|
||||
self.limits = limits or ArchiveLimits()
|
||||
self._original = self._spool = self._tar = None
|
||||
self._raw = None
|
||||
self.members = {}
|
||||
|
||||
def __enter__(self):
|
||||
try:
|
||||
self._original = open(self.path, "rb")
|
||||
archive_size = os.fstat(self._original.fileno()).st_size
|
||||
if archive_size > self.limits.max_archive_bytes:
|
||||
raise AdapterError("ARCHIVE_LIMIT", "Compressed source exceeds configured limit")
|
||||
self._raw = self._original.read(self.limits.max_archive_bytes + 1)
|
||||
if len(self._raw) > self.limits.max_archive_bytes:
|
||||
raise AdapterError("ARCHIVE_LIMIT", "Source archive changed or exceeds configured limit")
|
||||
archive_size = len(self._raw)
|
||||
self._spool = tempfile.SpooledTemporaryFile(max_size=4 * 1024**2)
|
||||
size = 0
|
||||
with gzip.GzipFile(fileobj=io.BytesIO(self._raw)) as compressed:
|
||||
while chunk := compressed.read(CHUNK_SIZE):
|
||||
size += len(chunk)
|
||||
if size > self.limits.max_tar_bytes or size > max(archive_size, 1) * self.limits.max_ratio:
|
||||
raise AdapterError("EXPANSION_LIMIT", "TAR expansion exceeds configured policy")
|
||||
self._spool.write(chunk)
|
||||
self._spool.seek(0)
|
||||
header_limit = self.limits.max_header_bytes
|
||||
|
||||
class BoundedTarInfo(tarfile.TarInfo):
|
||||
def _proc_member(self, archive):
|
||||
metadata_types = (tarfile.XHDTYPE, tarfile.XGLTYPE, tarfile.SOLARIS_XHDTYPE, tarfile.GNUTYPE_LONGNAME, tarfile.GNUTYPE_LONGLINK)
|
||||
if self.type not in metadata_types and (self.type == tarfile.GNUTYPE_SPARSE or not (self.isfile() or self.isdir())):
|
||||
raise AdapterError("SPECIAL_MEMBER", "Special TAR member rejected before extended processing", self.name)
|
||||
return super()._proc_member(archive)
|
||||
|
||||
def _proc_pax(self, *args, **kwargs):
|
||||
if self.size < 0 or self.size > header_limit:
|
||||
raise AdapterError("HEADER_LIMIT", "PAX metadata exceeds configured limit")
|
||||
return super()._proc_pax(*args, **kwargs)
|
||||
|
||||
def _proc_gnulong(self, *args, **kwargs):
|
||||
if self.size < 0 or self.size > header_limit:
|
||||
raise AdapterError("HEADER_LIMIT", "GNU metadata exceeds configured limit")
|
||||
return super()._proc_gnulong(*args, **kwargs)
|
||||
|
||||
self._tar = tarfile.open(fileobj=self._spool, mode="r:", tarinfo=BoundedTarInfo)
|
||||
names = {}
|
||||
for member in self._tar:
|
||||
if len(names) >= self.limits.max_entries:
|
||||
raise AdapterError("ENTRY_LIMIT", "Too many TAR entries")
|
||||
name = member.name
|
||||
if not safe_path(name, directory=member.isdir()):
|
||||
raise AdapterError("UNSAFE_PATH", "Unsafe TAR member path", name)
|
||||
if not (member.isfile() or member.isdir()) or member.issparse():
|
||||
raise AdapterError("SPECIAL_MEMBER", "Links, sparse files and special TAR entries are unsupported", name)
|
||||
if member.size < 0 or member.size > self.limits.max_file_bytes or (member.isdir() and member.size):
|
||||
raise AdapterError("FILE_LIMIT", "TAR member exceeds configured limit", name)
|
||||
normalized = name.rstrip("/").casefold()
|
||||
if normalized in names:
|
||||
raise AdapterError("MEMBER_COLLISION", "Duplicate or case-colliding TAR entry", name)
|
||||
names[normalized] = member.isdir()
|
||||
if member.isfile():
|
||||
self.members[name] = member
|
||||
for name in names:
|
||||
parts = name.split("/")
|
||||
if any(names.get("/".join(parts[:index])) is False for index in range(1, len(parts))):
|
||||
raise AdapterError("MEMBER_COLLISION", "A TAR file is also used as a directory", name)
|
||||
candidates = [name for name in self.members if name.split("/")[-1] == "manifest.json" and len(name.split("/")) <= 2]
|
||||
if len(candidates) != 1:
|
||||
raise AdapterError("MANIFEST_AMBIGUOUS", "Expected exactly one package-level source manifest")
|
||||
self.manifest_member = candidates[0]
|
||||
self.root = self.manifest_member.removesuffix("manifest.json")
|
||||
self.manifest = self.read_json("manifest.json")
|
||||
if not isinstance(self.manifest, dict):
|
||||
raise AdapterError("SOURCE_MANIFEST", "Source manifest must be an object")
|
||||
return self
|
||||
except AdapterError:
|
||||
self.close()
|
||||
raise
|
||||
except (OSError, EOFError, tarfile.TarError, zlib.error, ValueError, RecursionError) as error:
|
||||
self.close()
|
||||
raise AdapterError("ARCHIVE_INVALID", str(error)) from error
|
||||
|
||||
def member_name(self, relative: str) -> str:
|
||||
if not safe_path(relative) or self.root + relative not in self.members:
|
||||
raise AdapterError("SOURCE_REFERENCE", "Referenced source member is absent or unsafe", relative)
|
||||
return self.root + relative
|
||||
|
||||
def read_member(self, name: str, limit: int | None = None) -> bytes:
|
||||
if self._tar is None:
|
||||
raise RuntimeError("Use ArchiveReader in a with block")
|
||||
if name not in self.members:
|
||||
raise AdapterError("SOURCE_REFERENCE", "TAR member not found", name)
|
||||
limit = min(limit or self.limits.max_file_bytes, self.limits.max_file_bytes)
|
||||
if self.members[name].size > limit:
|
||||
raise AdapterError("FILE_LIMIT", "Source member exceeds configured read limit", name)
|
||||
try:
|
||||
with self._tar.extractfile(self.members[name]) as stream:
|
||||
data = stream.read(limit + 1)
|
||||
if len(data) != self.members[name].size or len(data) > limit:
|
||||
raise AdapterError("MEMBER_TRUNCATED", "TAR member length differs from header", name)
|
||||
return data
|
||||
except (OSError, EOFError, tarfile.TarError, ValueError) as error:
|
||||
raise AdapterError("ARCHIVE_INVALID", str(error), name) from error
|
||||
|
||||
def read_json(self, relative: str):
|
||||
name = self.member_name(relative)
|
||||
try:
|
||||
return parse_manifest(self.read_member(name, self.limits.max_json_bytes))
|
||||
except (ValueError, RecursionError) as error:
|
||||
raise AdapterError("SOURCE_JSON", str(error), name) from error
|
||||
|
||||
def original_bytes(self) -> bytes:
|
||||
if self._raw is None:
|
||||
raise RuntimeError("ArchiveReader is closed")
|
||||
return self._raw
|
||||
|
||||
def close(self):
|
||||
for stream in (self._tar, self._spool, self._original):
|
||||
if stream is not None:
|
||||
stream.close()
|
||||
self._tar = self._spool = self._original = None
|
||||
self._raw = None
|
||||
self.members = {}
|
||||
|
||||
def __exit__(self, *args):
|
||||
self.close()
|
||||
@@ -0,0 +1,105 @@
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from viano_bug_analysis.lifecycle.storage import json_bytes
|
||||
from viano_bug_analysis.package.manifest import FORMAT_CHECKER
|
||||
|
||||
from .errors import AdapterError
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdaptedInputs:
|
||||
additions: dict[str, bytes] = field(default_factory=dict)
|
||||
reports: list[str] = field(default_factory=list)
|
||||
reproductions: list[str] = field(default_factory=list)
|
||||
attachments: list[str] = field(default_factory=list)
|
||||
context: dict = field(default_factory=lambda: {key: [] for key in ("runtime", "database", "source", "research")})
|
||||
issues: list[dict] = field(default_factory=list)
|
||||
|
||||
|
||||
def check_manifest(reader, tool: str, version: int):
|
||||
manifest = reader.manifest
|
||||
if manifest.get("tool") != tool or type(manifest.get("format_version")) is not int or manifest["format_version"] != version or manifest.get("command") != "bug":
|
||||
raise AdapterError("SOURCE_FORMAT", f"Expected {tool} bug package format {version}")
|
||||
if type(manifest.get("bug_id")) is not int or manifest["bug_id"] < 0 or not isinstance(manifest.get("tool_version"), str) or not manifest["tool_version"].strip():
|
||||
raise AdapterError("SOURCE_MANIFEST", "Source requires bug_id and tool_version")
|
||||
if not isinstance(manifest.get("warnings", []), list) or any(not isinstance(value, str) for value in manifest.get("warnings", [])):
|
||||
raise AdapterError("SOURCE_MANIFEST", "Source warnings must be a list of strings")
|
||||
if manifest.get("core_revision") is not None and not isinstance(manifest["core_revision"], str):
|
||||
raise AdapterError("SOURCE_MANIFEST", "core_revision must be text or null")
|
||||
|
||||
|
||||
def payload_ref(reader, prefix: str, relative: str) -> str:
|
||||
return prefix + "/members/" + reader.member_name(relative)
|
||||
|
||||
|
||||
def origin(reader, prefix, relative, pointer, timestamp=None, decoded=None):
|
||||
result = {"source_ref": payload_ref(reader, prefix, relative), "json_pointer": pointer,
|
||||
"archive_ref": prefix + "/archive.tar.gz", "member_name": reader.member_name(relative)}
|
||||
if timestamp is not None:
|
||||
result["source_timestamp"] = timestamp
|
||||
if decoded is not None:
|
||||
result["decoded_json_pointer"] = decoded
|
||||
return result
|
||||
|
||||
|
||||
def materialize(reader, prefix, role_for) -> AdaptedInputs:
|
||||
result = AdaptedInputs()
|
||||
original = reader.original_bytes()
|
||||
result.additions[prefix + "/archive.tar.gz"] = original
|
||||
total = len(original)
|
||||
for name in sorted(reader.members):
|
||||
if total + reader.members[name].size > reader.limits.max_import_bytes:
|
||||
raise AdapterError("IMPORT_LIMIT", "Materialized source exceeds configured memory/import budget")
|
||||
data = reader.read_member(name)
|
||||
total += len(data)
|
||||
if total > reader.limits.max_import_bytes:
|
||||
raise AdapterError("IMPORT_LIMIT", "Materialized source exceeds configured memory/import budget")
|
||||
path = prefix + "/members/" + name
|
||||
result.additions[path] = data
|
||||
logical = name.removeprefix(reader.root) if name.startswith(reader.root) else None
|
||||
role = role_for(logical) if logical is not None else None
|
||||
if role == "attachment":
|
||||
result.attachments.append(path)
|
||||
elif role is not None:
|
||||
result.context[role].append(path)
|
||||
return result
|
||||
|
||||
|
||||
def rows(reader, relative):
|
||||
value = reader.read_json(relative)
|
||||
if not isinstance(value, list) or any(not isinstance(row, dict) for row in value):
|
||||
raise AdapterError("SOURCE_ROWS", "Expected a JSON array of objects", relative)
|
||||
return value
|
||||
|
||||
|
||||
def statement(text, reader, prefix, relative, pointer, author=None, timestamp=None, decoded=None, issues=None):
|
||||
if text is None or text == "":
|
||||
return None
|
||||
if not isinstance(text, str):
|
||||
raise AdapterError("SOURCE_FIELD", "Expected textual observation", relative + pointer)
|
||||
if not text.strip():
|
||||
return None
|
||||
result = {"text": text, "origin": origin(reader, prefix, relative, pointer, timestamp, decoded)}
|
||||
if author is not None:
|
||||
if not isinstance(author, str):
|
||||
raise AdapterError("SOURCE_FIELD", "Expected textual author")
|
||||
if author.strip():
|
||||
result["author"] = author
|
||||
if timestamp is not None:
|
||||
if not isinstance(timestamp, str):
|
||||
raise AdapterError("SOURCE_FIELD", "Expected timestamp text")
|
||||
if FORMAT_CHECKER.conforms(timestamp, "date-time"):
|
||||
result["observed_at"] = timestamp
|
||||
elif issues is not None:
|
||||
issues.append({"code": "SOURCE_TIME_UNQUALIFIED", "message": "Source time has no usable timezone; retained without conversion", "origin": result["origin"]})
|
||||
return result
|
||||
|
||||
|
||||
def write_document(result, path, document, contract):
|
||||
from viano_bug_analysis.case.contracts import validate
|
||||
from viano_bug_analysis.case.errors import CaseBuildError
|
||||
try:
|
||||
validate(document, contract, path)
|
||||
result.additions[path] = json_bytes(document)
|
||||
except (CaseBuildError, ValueError, TypeError) as error:
|
||||
raise AdapterError("NORMALIZATION_ERROR", str(error), path) from error
|
||||
@@ -0,0 +1,5 @@
|
||||
class AdapterError(Exception):
|
||||
def __init__(self, code: str, message: str, path: str | None = None):
|
||||
self.code = code
|
||||
self.path = path
|
||||
super().__init__(message)
|
||||
@@ -0,0 +1,202 @@
|
||||
import hashlib
|
||||
from contextlib import ExitStack
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
|
||||
from viano_bug_analysis.case.contracts import validate
|
||||
from viano_bug_analysis.case.errors import CaseBuildError
|
||||
from viano_bug_analysis.lifecycle.job import timestamp
|
||||
from viano_bug_analysis.lifecycle.storage import json_bytes
|
||||
from viano_bug_analysis.package import PackageReader, PackageReadError, PackageWriter, PackageWriteError
|
||||
from viano_bug_analysis.package.factory import PackageFactory
|
||||
from viano_bug_analysis.package.manifest import parse_manifest
|
||||
|
||||
from .archive_reader import ArchiveLimits, ArchiveReader
|
||||
from .common import check_manifest, write_document
|
||||
from .errors import AdapterError
|
||||
from .wowdiag_adapter import WowdiagAdapter
|
||||
from .wowsrc_adapter import WowsrcAdapter
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ImportPlan:
|
||||
additions: dict[str, bytes]
|
||||
index_ref: str
|
||||
receipt_ref: str
|
||||
bug_id: int
|
||||
reproductions_count: int
|
||||
|
||||
|
||||
class VcoreImporter:
|
||||
def __init__(self, archive_limits: ArchiveLimits | None = None, package_limits=None):
|
||||
self.archive_limits = archive_limits or ArchiveLimits()
|
||||
self.package_limits = package_limits
|
||||
|
||||
def _package_json(self, reader, path):
|
||||
with reader._archive.open(path) as stream:
|
||||
data = reader.validator._read_bounded(stream, min(self.archive_limits.max_json_bytes, reader.validator.limits.max_file_bytes))
|
||||
return parse_manifest(data)
|
||||
|
||||
def _merge_inputs(self, reader, plan):
|
||||
old_ref = reader.manifest["artifacts"].get("case_inputs")
|
||||
if not old_ref:
|
||||
return dict(plan.additions)
|
||||
old = self._package_json(reader, old_ref)
|
||||
new = parse_manifest(plan.additions[plan.index_ref])
|
||||
validate(old, "case-inputs")
|
||||
if any(len(old.get(field, [])) != len(set(old.get(field, []))) for field in ("reports", "reproductions", "attachments")):
|
||||
raise AdapterError("SOURCE_INDEX", "Existing input index repeats source documents")
|
||||
old_documents = set(old.get("reports", []) + old.get("reproductions", []) + [old_ref])
|
||||
if any(path not in reader.files for path in old_documents):
|
||||
raise AdapterError("SOURCE_REFERENCE", "Existing input index has unresolved documents")
|
||||
if sum(reader._archive.getinfo(path).file_size for path in old_documents) + sum(map(len, plan.additions.values())) > self.archive_limits.max_import_bytes:
|
||||
raise AdapterError("IMPORT_LIMIT", "Existing structured views plus new import exceed configured budget")
|
||||
# Reuse the same normalized source row when supplementing a diag-only
|
||||
# import with wowsrc. No unrelated existing input roles are dropped.
|
||||
def canonical(value):
|
||||
if isinstance(value, dict):
|
||||
result = {}
|
||||
for key, child in value.items():
|
||||
if key == "value":
|
||||
result[key] = child # arbitrary fact/claim JSON is not metadata
|
||||
elif key == "origin" and isinstance(child, dict):
|
||||
result[key] = {k: v for k, v in child.items() if k not in {"source_ref", "archive_ref"}}
|
||||
elif key == "events_ref" and isinstance(child, str):
|
||||
result[key] = child.split("/members/", 1)[-1]
|
||||
else:
|
||||
result[key] = canonical(child)
|
||||
return result
|
||||
if isinstance(value, list):
|
||||
return [canonical(child) for child in value]
|
||||
return value
|
||||
|
||||
def signature(document, raw_hash):
|
||||
source = document.get("origin")
|
||||
if not isinstance(source, dict) or not raw_hash:
|
||||
return None
|
||||
return raw_hash, source.get("member_name"), source.get("json_pointer"), json_bytes(canonical(document))
|
||||
|
||||
for field, contract in (("reports", "report"), ("reproductions", "reproduction")):
|
||||
known = {}
|
||||
for path in old.get(field, []):
|
||||
document = self._package_json(reader, path)
|
||||
validate(document, contract)
|
||||
archive = document.get("origin", {}).get("archive_ref")
|
||||
hash_ = reader.manifest["integrity"].get(archive, {}).get("sha256")
|
||||
token = signature(document, hash_)
|
||||
if token is not None:
|
||||
known[token] = path
|
||||
selected = []
|
||||
for path in new.get(field, []):
|
||||
document = parse_manifest(plan.additions[path])
|
||||
archive = document.get("origin", {}).get("archive_ref")
|
||||
hash_ = hashlib.sha256(plan.additions[archive]).hexdigest() if archive in plan.additions else None
|
||||
selected.append(known.get(signature(document, hash_), path))
|
||||
new[field] = list(dict.fromkeys(old.get(field, []) + selected))
|
||||
new["attachments"] = list(dict.fromkeys(old.get("attachments", []) + new.get("attachments", [])))
|
||||
context = dict(old.get("context", {}))
|
||||
for role, paths in new.get("context", {}).items():
|
||||
context[role] = list(dict.fromkeys(context.get(role, []) + paths))
|
||||
new["context"] = context
|
||||
merged = old | new
|
||||
validate(merged, "case-inputs")
|
||||
return dict(plan.additions) | {plan.index_ref: json_bytes(merged)}
|
||||
|
||||
def prepare(self, wowdiag, wowsrc=None) -> ImportPlan:
|
||||
try:
|
||||
if sum(Path(path).stat().st_size for path in (wowdiag, wowsrc) if path is not None) > self.archive_limits.max_import_bytes:
|
||||
raise AdapterError("IMPORT_LIMIT", "Combined original archives exceed configured budget")
|
||||
with ExitStack() as stack:
|
||||
diag = stack.enter_context(ArchiveReader(wowdiag, self.archive_limits))
|
||||
check_manifest(diag, "wowdiag", 2)
|
||||
src = stack.enter_context(ArchiveReader(wowsrc, self.archive_limits)) if wowsrc is not None else None
|
||||
if src:
|
||||
check_manifest(src, "wowsrc", 7)
|
||||
embedded = src.read_json("input/wowdiag_manifest.json")
|
||||
if src.manifest["bug_id"] != diag.manifest["bug_id"] or src.manifest.get("core_revision") != diag.manifest.get("core_revision") or json_bytes(embedded) != json_bytes(diag.manifest) or src.manifest.get("input_wowdiag_format") != 2 or src.manifest.get("input_wowdiag_version") != diag.manifest["tool_version"]:
|
||||
raise AdapterError("SOURCE_PAIR_MISMATCH", "wowsrc does not describe the supplied wowdiag package")
|
||||
hashes = [hashlib.sha256(reader.original_bytes()).hexdigest() for reader in (diag, src) if reader is not None]
|
||||
import_id = hashlib.sha256(json_bytes(hashes)).hexdigest()
|
||||
raw_prefix = f"original/vcore/imports/{import_id}"
|
||||
normalized_prefix = f"case/inputs/vcore/{import_id}"
|
||||
inputs = WowdiagAdapter().adapt(diag, raw_prefix + "/wowdiag", normalized_prefix + "/wowdiag")
|
||||
if not diag.manifest.get("core_revision"):
|
||||
inputs.issues.append({"code": "CORE_REVISION_MISSING", "message": "No core revision was supplied; matching revisions cannot be verified"})
|
||||
if src:
|
||||
remaining = self.archive_limits.max_import_bytes - sum(map(len, inputs.additions.values()))
|
||||
if remaining <= 0:
|
||||
raise AdapterError("IMPORT_LIMIT", "No remaining source import budget")
|
||||
src.limits = replace(src.limits, max_import_bytes=remaining)
|
||||
sources = WowsrcAdapter().adapt(src, raw_prefix + "/wowsrc")
|
||||
inputs.additions.update(sources.additions)
|
||||
for role, refs in sources.context.items():
|
||||
inputs.context[role].extend(refs)
|
||||
inputs.issues.extend(sources.issues)
|
||||
if sum(map(len, inputs.additions.values())) > self.archive_limits.max_import_bytes:
|
||||
raise AdapterError("IMPORT_LIMIT", "Combined imported sources exceed configured budget")
|
||||
quality = {"format": "viano-bug-report", "format_version": 1,
|
||||
"data_quality_issues": [{"code": issue["code"], "message": issue["message"], **({"origin": issue["origin"]} if "origin" in issue else {})} for issue in inputs.issues]}
|
||||
quality_ref = normalized_prefix + "/source-quality.json"
|
||||
write_document(inputs, quality_ref, quality, "report")
|
||||
inputs.reports.append(quality_ref)
|
||||
receipt_ref = normalized_prefix + "/import.json"
|
||||
receipt = {"format": "viano-vcore-import", "format_version": 1, "adapter_version": "1", "bug_id": diag.manifest["bug_id"],
|
||||
"sources": [{"tool": reader.manifest["tool"], "tool_version": reader.manifest["tool_version"],
|
||||
"format_version": reader.manifest["format_version"], "sha256": hash_,
|
||||
"archive_ref": raw_prefix + "/" + reader.manifest["tool"] + "/archive.tar.gz"}
|
||||
for reader, hash_ in zip((diag, src), hashes)], "issues": inputs.issues}
|
||||
inputs.additions[receipt_ref] = json_bytes(receipt)
|
||||
inputs.context["runtime"].append(receipt_ref)
|
||||
index_ref = normalized_prefix + "/case-inputs.json"
|
||||
index = {"format": "viano-case-inputs", "format_version": 1, "reports": inputs.reports,
|
||||
"reproductions": inputs.reproductions, "context": inputs.context, "attachments": inputs.attachments}
|
||||
validate(index, "case-inputs")
|
||||
inputs.additions[index_ref] = json_bytes(index)
|
||||
if sum(map(len, inputs.additions.values())) > self.archive_limits.max_import_bytes:
|
||||
raise AdapterError("IMPORT_LIMIT", "Normalized import exceeds configured budget")
|
||||
return ImportPlan(inputs.additions, index_ref, receipt_ref, diag.manifest["bug_id"], len(inputs.reproductions))
|
||||
except (ValueError, TypeError, OSError, CaseBuildError) as error:
|
||||
raise AdapterError("IMPORT_ERROR", str(error)) from error
|
||||
|
||||
def create(self, wowdiag, destination, *, package_id: str, case_id: str, wowsrc=None):
|
||||
plan = self.prepare(wowdiag, wowsrc)
|
||||
created = timestamp()
|
||||
manifest = {"format": "viano-bug-case-package", "format_version": 1, "package_id": package_id, "package_generation": 0,
|
||||
"case_id": case_id, "project": {"type": "wow", "profile": "vcore"}, "created_at": created, "updated_at": created,
|
||||
"state": "NEW", "routing": {"destination": "inbox", "reason": None, "retry_count": 0, "retry_not_before": None},
|
||||
"analysis_revision": 0, "artifacts": {"case": None, "diagnosis": None, "change_request": None,
|
||||
"case_inputs": plan.index_ref, "vcore_import": plan.receipt_ref},
|
||||
"history": {"analysis_revisions": 0, "reproductions": plan.reproductions_count, "evidence": 0, "handoffs": 0, "feedback": 0, "events_ref": None}, "integrity": {}}
|
||||
try:
|
||||
return PackageFactory(self.package_limits).create(destination, manifest, plan.additions)
|
||||
except PackageWriteError as error:
|
||||
raise AdapterError("PACKAGE_ERROR", str(error)) from error
|
||||
|
||||
def augment(self, source, destination, *, wowdiag, wowsrc=None):
|
||||
plan = self.prepare(wowdiag, wowsrc)
|
||||
try:
|
||||
with PackageReader(source, self.package_limits) as reader:
|
||||
manifest = reader.manifest
|
||||
if manifest["project"]["type"] != "wow" or manifest["project"]["profile"] != "vcore":
|
||||
raise AdapterError("PROJECT_MISMATCH", "Vcore inputs require a wow/vcore case")
|
||||
old_receipt = manifest["artifacts"].get("vcore_import")
|
||||
if old_receipt:
|
||||
previous = self._package_json(reader, old_receipt)
|
||||
if not isinstance(previous, dict) or previous.get("format") != "viano-vcore-import" or type(previous.get("format_version")) is not int or previous["format_version"] != 1:
|
||||
raise AdapterError("IMPORT_RECEIPT", "Unsupported or malformed previous import receipt", old_receipt)
|
||||
if previous.get("bug_id") != plan.bug_id:
|
||||
raise AdapterError("BUG_ID_MISMATCH", "Cannot attach a different source bug to this case")
|
||||
additions = self._merge_inputs(reader, plan)
|
||||
for name, data in additions.items():
|
||||
if name in reader.files and reader.read_bytes(name) != data:
|
||||
raise AdapterError("IMPORT_COLLISION", "An existing import artifact has different content", name)
|
||||
if manifest["artifacts"].get("vcore_import") == plan.receipt_ref and manifest["artifacts"].get("case_inputs") == plan.index_ref:
|
||||
raise AdapterError("IMPORT_EXISTS", "Identical source archives are already selected; use the existing package")
|
||||
changes = {name: data for name, data in additions.items() if name not in reader.files}
|
||||
return PackageWriter(self.package_limits).write(source, destination, additions=changes,
|
||||
manifest_updates={"artifacts": {"case_inputs": plan.index_ref, "vcore_import": plan.receipt_ref},
|
||||
"history": {"reproductions": max(manifest["history"]["reproductions"], plan.reproductions_count)}})
|
||||
except (PackageReadError, PackageWriteError) as error:
|
||||
raise AdapterError("PACKAGE_ERROR", str(error)) from error
|
||||
except (OSError, ValueError, TypeError, KeyError, RecursionError, CaseBuildError) as error:
|
||||
raise AdapterError("IMPORT_ERROR", str(error)) from error
|
||||
@@ -0,0 +1,112 @@
|
||||
from viano_bug_analysis.package.manifest import parse_manifest
|
||||
|
||||
from .common import check_manifest, materialize, origin, payload_ref, rows, statement, write_document
|
||||
from .errors import AdapterError
|
||||
|
||||
|
||||
class WowdiagAdapter:
|
||||
def adapt(self, reader, prefix, normalized_prefix=None):
|
||||
check_manifest(reader, "wowdiag", 2)
|
||||
role_for = lambda path: "attachment" if path.startswith("attachments/") else "database" if path.startswith("database/") else "source" if path.startswith("source/") else "runtime" if path.startswith(("bug/", "environment/", "findings/")) or path in {"manifest.json", "findings.json"} else None
|
||||
result = materialize(reader, prefix, role_for)
|
||||
normalized_prefix = normalized_prefix or prefix + "/normalized"
|
||||
bug = reader.read_json("bug/bug.json")
|
||||
if not isinstance(bug, dict) or type(bug.get("bug_id")) is not int or bug["bug_id"] != reader.manifest["bug_id"]:
|
||||
raise AdapterError("BUG_ID_MISMATCH", "Bug record disagrees with source manifest")
|
||||
reports = rows(reader, "bug/reports.json")
|
||||
reproductions = rows(reader, "bug/reproductions.json")
|
||||
events = rows(reader, "bug/reproduction_events.json")
|
||||
if not reports:
|
||||
result.issues.append({"code": "SOURCE_REPORTS_MISSING", "message": "wowdiag supplied no individual bug reports"})
|
||||
for relative, records, id_field in (("bug/reports.json", reports, "report_id"), ("bug/reproductions.json", reproductions, "reproduction_id"), ("bug/reproduction_events.json", events, "event_id")):
|
||||
identities = set()
|
||||
for row in records:
|
||||
identity = row.get(id_field)
|
||||
if type(identity) is not int or identity < 0 or identity in identities:
|
||||
raise AdapterError("SOURCE_ID", "Missing, invalid or duplicate source row ID", relative)
|
||||
identities.add(identity)
|
||||
if row.get("bug_id") is not None and (type(row["bug_id"]) is not int or row["bug_id"] != reader.manifest["bug_id"]):
|
||||
raise AdapterError("BUG_ID_MISMATCH", "Source row belongs to another bug", relative)
|
||||
reproduction_ids = {row["reproduction_id"] for row in reproductions}
|
||||
report_ids = {row["report_id"] for row in reports}
|
||||
if any(event.get("reproduction_id") not in reproduction_ids for event in events) or any(row.get("report_id") is not None and row["report_id"] not in report_ids for row in reproductions):
|
||||
raise AdapterError("SOURCE_RELATION", "Source rows have unresolved reproduction/report links")
|
||||
if any(row.get("bug_id") is None and row.get("report_id") not in report_ids for row in reproductions):
|
||||
raise AdapterError("SOURCE_RELATION", "Reproduction needs a direct bug or linked report association")
|
||||
for index, row in enumerate(reports):
|
||||
normalized = {"format": "viano-bug-report", "format_version": 1,
|
||||
"observations": [], "expected_behavior": [], "comments": [], "tester_hypotheses": [],
|
||||
"origin": origin(reader, prefix, "bug/reports.json", f"/{index}"), "entities": []}
|
||||
title = row.get("title")
|
||||
if isinstance(title, str) and title.strip():
|
||||
normalized["title"] = title
|
||||
for field, destination in (("description", "observations"), ("expected_behavior", "expected_behavior")):
|
||||
value = statement(row.get(field), reader, prefix, "bug/reports.json", f"/{index}/{field}", row.get("character_name"), row.get("created_at"), issues=result.issues)
|
||||
if value:
|
||||
normalized[destination].append(value)
|
||||
# Free-form reproduction notes are supplied prose, not inferred steps.
|
||||
note = statement(row.get("reproduction_notes"), reader, prefix, "bug/reports.json", f"/{index}/reproduction_notes", row.get("character_name"), issues=result.issues)
|
||||
if note:
|
||||
normalized["comments"].append(note)
|
||||
self._entity(normalized, row.get("target_type"), row.get("target_entry"), "reported_target", origin(reader, prefix, "bug/reports.json", f"/{index}/target_entry"))
|
||||
self._entity(normalized, "quest", row.get("quest_id"), "explicit_quest", origin(reader, prefix, "bug/reports.json", f"/{index}/quest_id"))
|
||||
self._entity(normalized, reader.manifest.get("diagnostic_focus_type"), reader.manifest.get("diagnostic_focus_id"), "diagnostic_focus", origin(reader, prefix, "manifest.json", "/diagnostic_focus_id"))
|
||||
path = normalized_prefix + f"/report-{row['report_id']}.json"
|
||||
write_document(result, path, normalized, "report")
|
||||
result.reports.append(path)
|
||||
# Preserve aggregate title/description as an explicitly separate supplied record.
|
||||
aggregate = {"format": "viano-bug-report", "format_version": 1, "origin": origin(reader, prefix, "bug/bug.json", ""), "entities": []}
|
||||
for field, destination in (("title", "title"), ("description", "summary")):
|
||||
value = bug.get(field)
|
||||
if isinstance(value, str) and value.strip():
|
||||
aggregate[destination] = value
|
||||
self._entity(aggregate, bug.get("primary_entity_type"), bug.get("primary_entity_id"), "reported_primary_entity", origin(reader, prefix, "bug/bug.json", "/primary_entity_id"))
|
||||
self._entity(aggregate, reader.manifest.get("diagnostic_focus_type"), reader.manifest.get("diagnostic_focus_id"), "diagnostic_focus", origin(reader, prefix, "manifest.json", "/diagnostic_focus_id"))
|
||||
if reader.root + "bug/runtime_entities.json" in reader.members:
|
||||
for index, entity in enumerate(rows(reader, "bug/runtime_entities.json")):
|
||||
self._entity(aggregate, entity.get("type"), entity.get("id"), "runtime_observed", origin(reader, prefix, "bug/runtime_entities.json", f"/{index}"))
|
||||
path = normalized_prefix + "/bug-record.json"
|
||||
write_document(result, path, aggregate, "report")
|
||||
result.reports.append(path)
|
||||
for index, row in enumerate(reproductions):
|
||||
normalized = {"format": "viano-reproduction", "format_version": 1,
|
||||
"origin": origin(reader, prefix, "bug/reproductions.json", f"/{index}"),
|
||||
"observations": [], "facts": [], "entities": [],
|
||||
"events_ref": payload_ref(reader, prefix, "bug/reproduction_events.json")}
|
||||
steps = row.get("steps")
|
||||
if steps is not None and not isinstance(steps, str):
|
||||
raise AdapterError("SOURCE_FIELD", "Source steps must be text or null")
|
||||
normalized["steps"] = [steps] if steps and steps.strip() else []
|
||||
observed = statement(row.get("observation"), reader, prefix, "bug/reproductions.json", f"/{index}/observation", row.get("tester_character_name"), row.get("finished_at"), issues=result.issues)
|
||||
if observed:
|
||||
normalized["observations"].append(observed)
|
||||
for event_index, event in enumerate(events):
|
||||
if event["reproduction_id"] == row["reproduction_id"] and event.get("source") == "tester" and event.get("event_type") == "tester_note":
|
||||
raw = event.get("data_json")
|
||||
try:
|
||||
decoded = parse_manifest(raw.encode("utf-8")) if isinstance(raw, str) else raw
|
||||
except (ValueError, RecursionError) as error:
|
||||
raise AdapterError("SOURCE_JSON", str(error), "bug/reproduction_events.json") from error
|
||||
if not isinstance(decoded, dict):
|
||||
raise AdapterError("SOURCE_FIELD", "Tester note data must be a JSON object")
|
||||
note = statement(decoded.get("text"), reader, prefix, "bug/reproduction_events.json", f"/{event_index}/data_json", row.get("tester_character_name"), event.get("event_time"), decoded="/text", issues=result.issues)
|
||||
if note:
|
||||
normalized["observations"].append(note)
|
||||
for field in ("result", "map_id", "zone_id", "area_id", "position_x", "position_y", "position_z", "orientation", "quest_id", "diagnostic_focus_type", "diagnostic_focus_id"):
|
||||
if row.get(field) is not None:
|
||||
normalized["facts"].append({"name": field, "value": row[field], "origin": origin(reader, prefix, "bug/reproductions.json", f"/{index}/{field}")})
|
||||
self._entity(normalized, row.get("diagnostic_focus_type"), row.get("diagnostic_focus_id"), "diagnostic_focus", origin(reader, prefix, "bug/reproductions.json", f"/{index}/diagnostic_focus_id"))
|
||||
path = normalized_prefix + f"/reproduction-{row['reproduction_id']}.json"
|
||||
write_document(result, path, normalized, "reproduction")
|
||||
result.reproductions.append(path)
|
||||
for warning in reader.manifest.get("warnings", []):
|
||||
result.issues.append({"code": "SOURCE_WARNING", "message": str(warning), "tool": "wowdiag"})
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _entity(document, type_, id_, role, origin_):
|
||||
if id_ is None or type_ is None:
|
||||
return
|
||||
if not isinstance(type_, str) or not type_.strip() or type(id_) is not int or id_ < 0:
|
||||
raise AdapterError("SOURCE_ENTITY", "Invalid typed source entity")
|
||||
document["entities"].append({"type": type_, "id": id_, "roles": [role], "origin": origin_})
|
||||
@@ -0,0 +1,25 @@
|
||||
from .common import check_manifest, materialize, payload_ref, rows
|
||||
from .errors import AdapterError
|
||||
|
||||
|
||||
class WowsrcAdapter:
|
||||
def adapt(self, reader, prefix):
|
||||
check_manifest(reader, "wowsrc", 7)
|
||||
role_for = lambda path: "database" if path.startswith("database/") else "source" if path.startswith(("source/", "build/", "custom/", "changes/", "environment/", "input/", "handoff/")) or path == "manifest.json" else None
|
||||
result = materialize(reader, prefix, role_for)
|
||||
for field in ("primary_development_context", "supporting_development_context", "context_priority_policy", "source_ranking_policy", "database_schema_index", "database_relationship_map"):
|
||||
if reader.manifest.get(field) is not None:
|
||||
payload_ref(reader, prefix, reader.manifest[field])
|
||||
for record in rows(reader, "source/development_files_index.json"):
|
||||
if record.get("copied") is True:
|
||||
path = record.get("package_path")
|
||||
name = reader.member_name(path)
|
||||
if type(record.get("size")) is not int or record["size"] != reader.members[name].size:
|
||||
raise AdapterError("SOURCE_SIZE", "Copied source size differs from its index", path)
|
||||
elif record.get("copied") is False:
|
||||
result.issues.append({"code": "SOURCE_NOT_COPIED", "message": str(record.get("reason_not_copied", "not copied")), "source_file": record.get("file")})
|
||||
else:
|
||||
raise AdapterError("SOURCE_INDEX", "Development file index requires copied boolean")
|
||||
for warning in reader.manifest.get("warnings", []):
|
||||
result.issues.append({"code": "SOURCE_WARNING", "message": str(warning), "tool": "wowsrc"})
|
||||
return result
|
||||
@@ -104,6 +104,9 @@
|
||||
"type": "null"
|
||||
}
|
||||
]
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "#/$defs/origin"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -221,6 +224,9 @@
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
}
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "#/$defs/origin"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -587,6 +593,9 @@
|
||||
"null"
|
||||
],
|
||||
"format": "date-time"
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "#/$defs/origin"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -652,6 +661,37 @@
|
||||
"$ref": "#/$defs/claim"
|
||||
}
|
||||
}
|
||||
},
|
||||
"origin": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"source_ref",
|
||||
"json_pointer"
|
||||
],
|
||||
"properties": {
|
||||
"source_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"json_pointer": {
|
||||
"type": "string",
|
||||
"pattern": "^(|/.*)$"
|
||||
},
|
||||
"archive_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"member_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"decoded_json_pointer": {
|
||||
"type": "string",
|
||||
"pattern": "^(|/.*)$"
|
||||
},
|
||||
"source_timestamp": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,6 +57,32 @@
|
||||
"items": {
|
||||
"$ref": "#/$defs/entity"
|
||||
}
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "#/$defs/origin"
|
||||
},
|
||||
"data_quality_issues": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"code",
|
||||
"message"
|
||||
],
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"message": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "#/$defs/origin"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
@@ -108,6 +134,9 @@
|
||||
},
|
||||
"claim": {
|
||||
"$ref": "#/$defs/claim"
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "#/$defs/origin"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -142,6 +171,40 @@
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
}
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "#/$defs/origin"
|
||||
}
|
||||
}
|
||||
},
|
||||
"origin": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"source_ref",
|
||||
"json_pointer"
|
||||
],
|
||||
"properties": {
|
||||
"source_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"json_pointer": {
|
||||
"type": "string",
|
||||
"pattern": "^(|/.*)$"
|
||||
},
|
||||
"archive_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"member_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"decoded_json_pointer": {
|
||||
"type": "string",
|
||||
"pattern": "^(|/.*)$"
|
||||
},
|
||||
"source_timestamp": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,7 +58,10 @@
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
},
|
||||
"value": {}
|
||||
"value": {},
|
||||
"origin": {
|
||||
"$ref": "#/$defs/origin"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -67,6 +70,9 @@
|
||||
"items": {
|
||||
"$ref": "#/$defs/entity"
|
||||
}
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "#/$defs/origin"
|
||||
}
|
||||
},
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
@@ -118,6 +124,9 @@
|
||||
},
|
||||
"claim": {
|
||||
"$ref": "#/$defs/claim"
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "#/$defs/origin"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -152,6 +161,40 @@
|
||||
"minLength": 1,
|
||||
"pattern": "\\S"
|
||||
}
|
||||
},
|
||||
"origin": {
|
||||
"$ref": "#/$defs/origin"
|
||||
}
|
||||
}
|
||||
},
|
||||
"origin": {
|
||||
"type": "object",
|
||||
"required": [
|
||||
"source_ref",
|
||||
"json_pointer"
|
||||
],
|
||||
"properties": {
|
||||
"source_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"json_pointer": {
|
||||
"type": "string",
|
||||
"pattern": "^(|/.*)$"
|
||||
},
|
||||
"archive_ref": {
|
||||
"type": "string",
|
||||
"minLength": 1
|
||||
},
|
||||
"member_name": {
|
||||
"type": "string"
|
||||
},
|
||||
"decoded_json_pointer": {
|
||||
"type": "string",
|
||||
"pattern": "^(|/.*)$"
|
||||
},
|
||||
"source_timestamp": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user