///|
/// Conflicts are review outcomes, never an instruction to choose a branch.
pub(all) enum MergeConflictKind {
FieldDiverged
SourceAddAdd
SourceDeleteEdit
ScanDeleteEdit
EffectiveDiverged
InheritanceShadow
UnresolvedMetadata
InvalidSnapshot
} derive(Eq, @debug.Debug)
///|
pub extend MergeConflictKind with Eq::{equal, not_equal}
///|
pub extend MergeConflictKind with @debug.Debug::{to_repr}
///|
/// Exact JSON evidence; None is absence, Some("null") is a present null.
/// For source conflicts evidence is a complete source object; for scan deletion
/// it is a complete resolution report. diagnostics identify unresolved inputs.
pub struct MergeConflict {
priv kind : MergeConflictKind
priv path : String
priv key : String?
priv base : Json?
priv left : Json?
priv right : Json?
priv merged : Json?
priv branch : String?
priv diagnostics : Array[Diagnostic]
}
///|
pub fn MergeConflict::kind(self : MergeConflict) -> MergeConflictKind {
self.kind
}
///|
pub fn MergeConflict::path(self : MergeConflict) -> String {
self.path
}
///|
pub fn MergeConflict::key(self : MergeConflict) -> String? {
self.key
}
///|
pub fn MergeConflict::base_json(self : MergeConflict) -> String? {
self.base.map(canonical_json)
}
///|
pub fn MergeConflict::left_json(self : MergeConflict) -> String? {
self.left.map(canonical_json)
}
///|
pub fn MergeConflict::right_json(self : MergeConflict) -> String? {
self.right.map(canonical_json)
}
///|
pub fn MergeConflict::merged_json(self : MergeConflict) -> String? {
self.merged.map(canonical_json)
}
///|
/// base, left, right or merged for resolution/validation errors; otherwise None.
pub fn MergeConflict::branch(self : MergeConflict) -> String? {
self.branch
}
///|
pub fn MergeConflict::message(self : MergeConflict) -> String {
match self.kind {
FieldDiverged =>
"Both branches changed this whole field differently; reconcile the values explicitly."
SourceAddAdd =>
"Both branches created different metadata at this source path; supply one agreed source."
SourceDeleteEdit =>
"One branch removed a source which the other edited; reconcile source inventory and metadata."
ScanDeleteEdit =>
"Removing this scan would discard the other branch's metadata or provenance change; reconcile its inclusion."
EffectiveDiverged =>
"Separate source edits changed this scan's effective field differently; reconcile inherited intent."
InheritanceShadow =>
"Combined inheritance would hide or alter a branch's effective field change; revise the overrides."
UnresolvedMetadata =>
"This scan cannot be resolved in the named snapshot; fix the attached diagnostic and retry."
InvalidSnapshot =>
"The combined snapshot violates an input invariant; fix the attached diagnostic and retry."
}
}
///|
pub fn MergeConflict::diagnostics(self : MergeConflict) -> Array[Diagnostic] {
self.diagnostics.map(d => {
code: d.code,
path: d.path,
message: d.message,
offset: d.offset,
related: d.related.copy(),
})
}
///|
/// An all-or-nothing proposal retaining all three immutable inputs.
/// A candidate exists only after structural and inheritance checks both pass.
pub struct MergeReport {
priv base : DatasetIndex
priv left : DatasetIndex
priv right : DatasetIndex
priv candidate : DatasetIndex?
priv conflicts : Array[MergeConflict]
}
///|
pub fn MergeReport::candidate(self : MergeReport) -> DatasetIndex? {
self.candidate
}
///|
pub fn MergeReport::conflicts(self : MergeReport) -> Array[MergeConflict] {
self.conflicts.copy()
}
///|
pub fn MergeReport::conflict_count(self : MergeReport) -> Int32 {
self.conflicts.length()
}
///|
/// Full base-to-candidate impact, including provenance. None when blocked.
pub fn MergeReport::impact(self : MergeReport) -> ImpactReport? {
self.candidate.map(after => self.base.compare(after))
}
///|
fn merge_kind_text(kind : MergeConflictKind) -> String {
match kind {
FieldDiverged => "field_diverged"
SourceAddAdd => "source_add_add"
SourceDeleteEdit => "source_delete_edit"
ScanDeleteEdit => "scan_delete_edit"
EffectiveDiverged => "effective_diverged"
InheritanceShadow => "inheritance_shadow"
UnresolvedMetadata => "unresolved_metadata"
InvalidSnapshot => "invalid_snapshot"
}
}
///|
fn merge_optional_value(value : Json?) -> Json {
// Presence remains explicit, since absent and present-null are distinct.
Json::object({
"present": Json::boolean(value is Some(_)),
"value": value.unwrap_or(Json::null()),
})
}
///|
fn MergeConflict::report_value(self : MergeConflict) -> Json {
Json::object({
"kind": Json::string(merge_kind_text(self.kind)),
"message": Json::string(self.message()),
"path": Json::string(self.path),
"key": self.key.map(Json::string).unwrap_or(Json::null()),
"base": merge_optional_value(self.base),
"left": merge_optional_value(self.left),
"right": merge_optional_value(self.right),
"merged": merge_optional_value(self.merged),
"branch": self.branch.map(Json::string).unwrap_or(Json::null()),
"diagnostics": Json::array(self.diagnostics.map(d => d.to_json())),
})
}
///|
/// Deterministic complete evidence. No partially merged manifest is exposed
/// when conflicts exist. Recompute with merge() from the three embedded inputs.
pub fn MergeReport::to_json(self : MergeReport) -> String {
canonical_json(
Json::object({
"status": Json::string(
if self.candidate is Some(_) {
"merged"
} else {
"conflicts"
},
),
"conflict_count": Json::number(self.conflict_count().to_double()),
"conflicts": Json::array(self.conflicts.map(c => c.report_value())),
"base_manifest": self.base.manifest_value(),
"left_manifest": self.left.manifest_value(),
"right_manifest": self.right.manifest_value(),
"merged_manifest": self.candidate
.map(i => i.manifest_value())
.unwrap_or(Json::null()),
"impact": self.impact().map(i => i.report_value()).unwrap_or(Json::null()),
}),
)
}
///|
fn merge_equal(a : Json?, b : Json?) -> Bool {
a.map(canonical_json) == b.map(canonical_json)
}
///|
fn merge_conflict(
conflicts : Array[MergeConflict],
kind : MergeConflictKind,
path : String,
key? : String,
base? : Json,
left? : Json,
right? : Json,
merged? : Json,
branch? : String,
diagnostics~ : Array[Diagnostic] = [],
) -> Unit raise TraceError {
if conflicts.length() >= 1024 {
reject(
"merge_conflict_limit", "", "More than 1,024 conflicts; narrow the snapshots or reconcile conflicts before retrying. No candidate was produced.",
)
}
conflicts.push({
kind,
path,
key,
base,
left,
right,
merged,
branch,
diagnostics,
})
}
///|
fn merge_sources(index : DatasetIndex) -> Map[String, Json] {
let sources : Map[String, Json] = Map([])
for source in index.sidecars {
sources[source.selector.path] = Json::object(source.metadata)
}
sources
}
///|
/// Three-way metadata merge over a shared ancestor, followed by per-scan
/// inheritance validation. Values are atomic at the top-level field boundary.
/// Conflicts block the entire candidate. More than 1,024 conflicts raises.
/// This is a conservative review policy, not a BIDS conformance certificate.
pub fn DatasetIndex::merge(
self : DatasetIndex,
left : DatasetIndex,
right : DatasetIndex,
) -> MergeReport raise TraceError {
self.merge_with(left, right, None)
}
///|
fn DatasetIndex::merge_with(
self : DatasetIndex,
left : DatasetIndex,
right : DatasetIndex,
resolution : MergeResolution?,
) -> MergeReport raise TraceError {
let conflicts : Array[MergeConflict] = []
let b = merge_sources(self)
let l = merge_sources(left)
let r = merge_sources(right)
let paths : Map[String, Bool] = Map([])
for sources in [b, l, r] {
for path in sorted_keys(sources) {
paths[path] = true
}
}
if resolution is Some(plan) {
for decision in plan.sources {
paths[decision.path] = true
}
}
let inputs : Array[SidecarInput] = []
for path in sorted_keys(paths) {
if resolution is Some(plan) && plan.source_at(path) is Some(decision) {
if decision.metadata is Some(metadata) {
inputs.push(SidecarInput::new(path, canonical_json(metadata)))
} else if !paths_in_inputs(self, left, right, path, source=true) {
reject(
"unused_resolution", path, "Cannot remove a source absent from all three inputs.",
)
}
continue
}
let bv = b.get(path)
let lv = l.get(path)
let rv = r.get(path)
let selected = if merge_equal(lv, rv) {
lv
} else if merge_equal(bv, lv) {
rv
} else if merge_equal(bv, rv) {
lv
} else {
match (bv, lv, rv) {
(Some(Object(bf)), Some(Object(lf)), Some(Object(rf))) => {
let keys : Map[String, Bool] = Map([])
for fields in [bf, lf, rf] {
for key in sorted_keys(fields) {
keys[key] = true
}
}
let merged : Map[String, Json] = Map([])
for key in sorted_keys(keys) {
let before = bf.get(key)
let a = lf.get(key)
let z = rf.get(key)
let value = if merge_equal(a, z) {
a
} else if merge_equal(before, a) {
z
} else if merge_equal(before, z) {
a
} else {
merge_conflict(
conflicts,
FieldDiverged,
path,
key~,
base?=before,
left?=a,
right?=z,
)
None
}
if value is Some(value) {
merged[key] = value
}
}
Some(Json::object(merged))
}
_ => {
merge_conflict(
conflicts,
if bv is None {
SourceAddAdd
} else {
SourceDeleteEdit
},
path,
base?=bv,
left?=lv,
right?=rv,
)
None
}
}
}
if selected is Some(metadata) {
inputs.push(SidecarInput::new(path, canonical_json(metadata)))
}
}
// Structural conflicts take precedence. Do not audit a speculative choice.
if !conflicts.is_empty() {
return { base: self, left, right, candidate: None, conflicts, }
}
let scans : Map[String, Bool] = Map([])
for index in [self, left, right] {
for path in index.data_paths() {
scans[path] = true
}
}
if resolution is Some(plan) {
for decision in plan.scans {
if !decision.retained && !scans.contains(decision.path) {
reject(
"unused_resolution",
decision.path,
"Cannot remove a scan absent from all three inputs.",
)
}
scans[decision.path] = true
}
}
let files = sorted_keys(scans).filter(path => {
if resolution is Some(plan) && plan.scan_at(path) is Some(decision) {
return decision.retained
}
let bp = self.file_positions.contains(path)
let lp = left.file_positions.contains(path)
let rp = right.file_positions.contains(path)
if lp == rp {
lp
} else if lp == bp {
rp
} else {
lp
}
})
let candidate = DatasetIndex::from_manifest(files, inputs) catch {
TraceError(d) => {
merge_conflict(conflicts, InvalidSnapshot, d.path, branch="merged", diagnostics=[
d,
])
return { base: self, left, right, candidate: None, conflicts, }
}
}
if resolution is Some(plan) {
plan.check_effective(candidate)
}
merge_check_scans(
self,
left,
right,
candidate,
sorted_keys(scans),
conflicts,
resolution~,
)
{
base: self,
left,
right,
candidate: if conflicts.is_empty() {
Some(candidate)
} else {
None
},
conflicts,
}
}