Repository: qir-alliance/pyqir (language: python)

Issue:
Add API to merge QIR modules
If would be beneficial to be able to merge QIR modules. A useful example would be decompositions. LLVM module metadata will enforce merging rules.
Add support for `LLVMContextSetDiagnosticHandler`
Module linking detailed errors are currently unavailable since we haven't plumbed through `LLVMContextSetDiagnosticHandler` support which is required to get errors from `LLVMLinkModules2`.



Discussion / hints:

Fix patch:
diff --git a/pyqir/pyqir/_native.pyi b/pyqir/pyqir/_native.pyi
index 0723dd50..4b151057 100644
--- a/pyqir/pyqir/_native.pyi
+++ b/pyqir/pyqir/_native.pyi
@@ -681,6 +681,15 @@ class Module:
         """Converts this module into an LLVM IR string."""
         ...
 
+    def link(self, other: Module) -> None:
+        """
+        Link the supplied module into the current module.
+        Destroys the supplied module.
+
+        :raises: An error if linking failed.
+        """
+        ...
+
 class ModuleFlagBehavior(Enum):
     """Module flag behavior choices"""
 
diff --git a/pyqir/src/module.rs b/pyqir/src/module.rs
index 8ee46652..1d6d2c53 100644
--- a/pyqir/src/module.rs
+++ b/pyqir/src/module.rs
@@ -9,6 +9,7 @@ use crate::{
     metadata::Metadata,
     values::{Constant, Owner, Value},
 };
+use core::mem::forget;
 use core::slice;
 #[allow(clippy::wildcard_imports, deprecated)]
 use llvm_sys::{
@@ -17,10 +18,11 @@ use llvm_sys::{
     bit_writer::LLVMWriteBitcodeToMemoryBuffer,
     core::*,
     ir_reader::LLVMParseIRInContext,
+    linker::LLVMLinkModules2,
     LLVMLinkage, LLVMModule,
 };
 use pyo3::{exceptions::PyValueError, prelude::*, pyclass::CompareOp, types::PyBytes};
-use qirlib::module::FlagBehavior;
+use qirlib::{context::set_diagnostic_handler, module::FlagBehavior};
 use std::{
     collections::hash_map::DefaultHasher,
     ffi::CString,
@@ -263,6 +265,37 @@ impl Module {
                 .to_string()
         }
     }
+
+    /// Link the supplied module into the current module.
+    /// Destroys the supplied module.
+    ///
+    /// :raises: An error if linking failed.
+    pub fn link(&self, other: Py<Module>, py: Python) -> PyResult<()> {
+        let context = self.context.borrow(py).as_ptr();
+        if context != other.borrow(py).context.borrow(py).as_ptr() {
+            return Err(PyValueError::new_err(
+                "Cannot link modules from different contexts. Modules are untouched.".to_string(),
+            ));
+        }
+        unsafe {
+            let mut c_char_output: *mut ::core::ffi::c_char = ptr::null_mut();
+            let output = ::core::ptr::from_mut::<*mut ::core::ffi::c_char>(&mut c_char_output)
+                .cast::<*mut ::core::ffi::c_void>()
+                .cast::<::core::ffi::c_void>();
+
+            set_diagnostic_handler(context, output);
+            let result = LLVMLinkModules2(self.module.as_ptr(), other.borrow(py).module.as_ptr());
+            // `forget` the other module. LLVM has destroyed it
+            // and we'll get a segfault if we drop it.
+            forget(other);
+            if result == 0 {
+                Ok(())
+            } else {
+                let error = Message::from_raw(c_char_output);
+                return Err(PyValueError::new_err(error.to_str().unwrap().to_string()));
+            }
+        }
+    }
 }
 
 impl Deref for Module {
diff --git a/qirlib/src/context.rs b/qirlib/src/context.rs
new file mode 100644
index 00000000..96b915c6
--- /dev/null
+++ b/qirlib/src/context.rs
@@ -0,0 +1,30 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+use llvm_sys::{
+    core::{LLVMContextSetDiagnosticHandler, LLVMGetDiagInfoDescription, LLVMGetDiagInfoSeverity},
+    prelude::{LLVMContextRef, LLVMDiagnosticInfoRef},
+    LLVMDiagnosticSeverity,
+};
+
+pub unsafe fn set_diagnostic_handler(
+    context: LLVMContextRef,
+    output_ptr: *mut ::core::ffi::c_void,
+) {
+    unsafe { LLVMContextSetDiagnosticHandler(context, Some(diagnostic_handler), output_ptr) };
+}
+
+pub(crate) extern "C" fn diagnostic_handler(
+    diagnostic_info: LLVMDiagnosticInfoRef,
+    output: *mut ::core::ffi::c_void,
+) {
+    unsafe {
+        let severity = LLVMGetDiagInfoSeverity(diagnostic_info);
+        if severity == LLVMDiagnosticSeverity::LLVMDSError {
+            let c_char_output = output
+                .cast::<*mut ::core::ffi::c_void>()
+                .cast::<*mut ::core::ffi::c_char>();
+            *c_char_output = LLVMGetDiagInfoDescription(diagnostic_info);
+        }
+    }
+}
diff --git a/qirlib/src/lib.rs b/qirlib/src/lib.rs
index 5711e787..038670f9 100644
--- a/qirlib/src/lib.rs
+++ b/qirlib/src/lib.rs
@@ -20,6 +20,8 @@ extern crate llvm_sys_140 as llvm_sys;
 #[cfg(not(feature = "no-llvm-linking"))]
 pub mod builder;
 #[cfg(not(feature = "no-llvm-linking"))]
+pub mod context;
+#[cfg(not(feature = "no-llvm-linking"))]
 pub(crate) mod llvm_wrapper;
 #[cfg(not(feature = "no-llvm-linking"))]
 pub mod metadata;


Test patch:
diff --git a/pyqir/tests/5_bit_random_number.ll b/pyqir/tests/5_bit_random_number.ll
new file mode 100644
index 00000000..9c0c7b8f
--- /dev/null
+++ b/pyqir/tests/5_bit_random_number.ll
@@ -0,0 +1,44 @@
+; ModuleID = '5_bit_random_number'
+%Result = type opaque
+%Qubit = type opaque
+
+define void @five_bit_random_number() #0 {
+block_0:
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 0 to %Qubit*))
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 1 to %Qubit*))
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 2 to %Qubit*))
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 3 to %Qubit*))
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 4 to %Qubit*))
+  call void @__quantum__qis__m__body(%Qubit* inttoptr (i64 0 to %Qubit*), %Result* inttoptr (i64 0 to %Result*))
+  call void @__quantum__qis__m__body(%Qubit* inttoptr (i64 1 to %Qubit*), %Result* inttoptr (i64 1 to %Result*))
+  call void @__quantum__qis__m__body(%Qubit* inttoptr (i64 2 to %Qubit*), %Result* inttoptr (i64 2 to %Result*))
+  call void @__quantum__qis__m__body(%Qubit* inttoptr (i64 3 to %Qubit*), %Result* inttoptr (i64 3 to %Result*))
+  call void @__quantum__qis__m__body(%Qubit* inttoptr (i64 4 to %Qubit*), %Result* inttoptr (i64 4 to %Result*))
+  call void @__quantum__rt__array_record_output(i64 5, i8* null)
+  call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 0 to %Result*), i8* null)
+  call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 1 to %Result*), i8* null)
+  call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 2 to %Result*), i8* null)
+  call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 3 to %Result*), i8* null)
+  call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 4 to %Result*), i8* null)
+  ret void
+}
+
+declare void @__quantum__qis__h__body(%Qubit*)
+
+declare void @__quantum__rt__array_record_output(i64, i8*)
+
+declare void @__quantum__rt__result_record_output(%Result*, i8*)
+
+declare void @__quantum__qis__m__body(%Qubit*, %Result*) #1
+
+attributes #0 = { "entry_point" "output_labeling_schema" "qir_profiles"="base_profile" "required_num_qubits"="5" "required_num_results"="5" }
+attributes #1 = { "irreversible" }
+
+; module flags
+
+!llvm.module.flags = !{!0, !1, !2, !3}
+
+!0 = !{i32 1, !"qir_major_version", i32 1}
+!1 = !{i32 7, !"qir_minor_version", i32 0}
+!2 = !{i32 1, !"dynamic_qubit_management", i1 false}
+!3 = !{i32 1, !"dynamic_result_management", i1 false}
\ No newline at end of file
diff --git a/pyqir/tests/combined_module.ll b/pyqir/tests/combined_module.ll
new file mode 100644
index 00000000..ebadbb78
--- /dev/null
+++ b/pyqir/tests/combined_module.ll
@@ -0,0 +1,56 @@
+
+%Qubit = type opaque
+%Result = type opaque
+
+define void @random_bit() #0 {
+block_0:
+  call void @__quantum__qis__h__body(%Qubit* null)
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 1 to %Qubit*))
+  call void @__quantum__qis__cz__body(%Qubit* inttoptr (i64 1 to %Qubit*), %Qubit* null)
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 1 to %Qubit*))
+  call void @__quantum__qis__m__body(%Qubit* inttoptr (i64 1 to %Qubit*), %Result* null)
+  call void @__quantum__rt__result_record_output(%Result* null, i8* null)
+  ret void
+}
+
+declare void @__quantum__qis__h__body(%Qubit*)
+
+declare void @__quantum__qis__cz__body(%Qubit*, %Qubit*)
+
+declare void @__quantum__rt__result_record_output(%Result*, i8*)
+
+declare void @__quantum__qis__m__body(%Qubit*, %Result*) #1
+
+define void @five_bit_random_number() #2 {
+block_0:
+  call void @__quantum__qis__h__body(%Qubit* null)
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 1 to %Qubit*))
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 2 to %Qubit*))
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 3 to %Qubit*))
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 4 to %Qubit*))
+  call void @__quantum__qis__m__body(%Qubit* null, %Result* null)
+  call void @__quantum__qis__m__body(%Qubit* inttoptr (i64 1 to %Qubit*), %Result* inttoptr (i64 1 to %Result*))
+  call void @__quantum__qis__m__body(%Qubit* inttoptr (i64 2 to %Qubit*), %Result* inttoptr (i64 2 to %Result*))
+  call void @__quantum__qis__m__body(%Qubit* inttoptr (i64 3 to %Qubit*), %Result* inttoptr (i64 3 to %Result*))
+  call void @__quantum__qis__m__body(%Qubit* inttoptr (i64 4 to %Qubit*), %Result* inttoptr (i64 4 to %Result*))
+  call void @__quantum__rt__array_record_output(i64 5, i8* null)
+  call void @__quantum__rt__result_record_output(%Result* null, i8* null)
+  call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 1 to %Result*), i8* null)
+  call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 2 to %Result*), i8* null)
+  call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 3 to %Result*), i8* null)
+  call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 4 to %Result*), i8* null)
+  ret void
+}
+
+declare void @__quantum__rt__array_record_output(i64, i8*)
+
+attributes #0 = { "entry_point" "output_labeling_schema" "qir_profiles"="base_profile" "required_num_qubits"="2" "required_num_results"="1" }
+attributes #1 = { "irreversible" }
+attributes #2 = { "entry_point" "output_labeling_schema" "qir_profiles"="base_profile" "required_num_qubits"="5" "required_num_results"="5" }
+
+!llvm.module.flags = !{!0, !1, !2, !3}
+
+!0 = !{i32 1, !"qir_major_version", i32 1}
+!1 = !{i32 7, !"qir_minor_version", i32 0}
+!2 = !{i32 1, !"dynamic_qubit_management", i1 false}
+!3 = !{i32 1, !"dynamic_result_management", i1 false}
diff --git a/pyqir/tests/profile_v1.0_compat.ll b/pyqir/tests/profile_v1.0_compat.ll
new file mode 100644
index 00000000..9a1ef1c2
--- /dev/null
+++ b/pyqir/tests/profile_v1.0_compat.ll
@@ -0,0 +1,34 @@
+; ModuleID = 'OneDotZero'
+%Result = type opaque
+%Qubit = type opaque
+
+define void @OneDotZero() #0 {
+block_0:
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 0 to %Qubit*))
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 1 to %Qubit*))
+  call void @__quantum__qis__cz__body(%Qubit* inttoptr (i64 1 to %Qubit*), %Qubit* inttoptr (i64 0 to %Qubit*))
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 1 to %Qubit*))
+  call void @__quantum__qis__m__body(%Qubit* inttoptr (i64 1 to %Qubit*), %Result* inttoptr (i64 0 to %Result*))
+  call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 0 to %Result*), i8* null)
+  ret void
+}
+
+declare void @__quantum__qis__h__body(%Qubit*)
+
+declare void @__quantum__qis__cz__body(%Qubit*, %Qubit*)
+
+declare void @__quantum__rt__result_record_output(%Result*, i8*)
+
+declare void @__quantum__qis__m__body(%Qubit*, %Result*) #1
+
+attributes #0 = { "entry_point" "output_labeling_schema" "qir_profiles"="base_profile" "required_num_qubits"="2" "required_num_results"="1" }
+attributes #1 = { "irreversible" }
+
+; module flags
+
+!llvm.module.flags = !{!0, !1, !2, !3}
+
+!0 = !{i32 1, !"qir_major_version", i32 1}
+!1 = !{i32 7, !"qir_minor_version", i32 0}
+!2 = !{i32 1, !"dynamic_qubit_management", i1 false}
+!3 = !{i32 1, !"dynamic_result_management", i1 false}
\ No newline at end of file
diff --git a/pyqir/tests/profile_v1.1_compat.ll b/pyqir/tests/profile_v1.1_compat.ll
new file mode 100644
index 00000000..1bf726d1
--- /dev/null
+++ b/pyqir/tests/profile_v1.1_compat.ll
@@ -0,0 +1,34 @@
+; ModuleID = 'OneDotOne'
+%Result = type opaque
+%Qubit = type opaque
+
+define void @OneDotOne() #0 {
+block_0:
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 0 to %Qubit*))
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 1 to %Qubit*))
+  call void @__quantum__qis__cz__body(%Qubit* inttoptr (i64 1 to %Qubit*), %Qubit* inttoptr (i64 0 to %Qubit*))
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 1 to %Qubit*))
+  call void @__quantum__qis__m__body(%Qubit* inttoptr (i64 1 to %Qubit*), %Result* inttoptr (i64 0 to %Result*))
+  call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 0 to %Result*), i8* null)
+  ret void
+}
+
+declare void @__quantum__qis__h__body(%Qubit*)
+
+declare void @__quantum__qis__cz__body(%Qubit*, %Qubit*)
+
+declare void @__quantum__rt__result_record_output(%Result*, i8*)
+
+declare void @__quantum__qis__m__body(%Qubit*, %Result*) #1
+
+attributes #0 = { "entry_point" "output_labeling_schema" "qir_profiles"="base_profile" "required_num_qubits"="2" "required_num_results"="1" }
+attributes #1 = { "irreversible" }
+
+; module flags
+
+!llvm.module.flags = !{!0, !1, !2, !3}
+
+!0 = !{i32 1, !"qir_major_version", i32 1}
+!1 = !{i32 7, !"qir_minor_version", i32 1}
+!2 = !{i32 1, !"dynamic_qubit_management", i1 false}
+!3 = !{i32 1, !"dynamic_result_management", i1 false}
\ No newline at end of file
diff --git a/pyqir/tests/profile_v2.0_compat.ll b/pyqir/tests/profile_v2.0_compat.ll
new file mode 100644
index 00000000..c51531ca
--- /dev/null
+++ b/pyqir/tests/profile_v2.0_compat.ll
@@ -0,0 +1,34 @@
+; ModuleID = 'TwoDotZero'
+%Result = type opaque
+%Qubit = type opaque
+
+define void @TwoDotZero() #0 {
+block_0:
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 0 to %Qubit*))
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 1 to %Qubit*))
+  call void @__quantum__qis__cz__body(%Qubit* inttoptr (i64 1 to %Qubit*), %Qubit* inttoptr (i64 0 to %Qubit*))
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 1 to %Qubit*))
+  call void @__quantum__qis__m__body(%Qubit* inttoptr (i64 1 to %Qubit*), %Result* inttoptr (i64 0 to %Result*))
+  call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 0 to %Result*), i8* null)
+  ret void
+}
+
+declare void @__quantum__qis__h__body(%Qubit*)
+
+declare void @__quantum__qis__cz__body(%Qubit*, %Qubit*)
+
+declare void @__quantum__rt__result_record_output(%Result*, i8*)
+
+declare void @__quantum__qis__m__body(%Qubit*, %Result*) #1
+
+attributes #0 = { "entry_point" "output_labeling_schema" "qir_profiles"="base_profile" "required_num_qubits"="2" "required_num_results"="1" }
+attributes #1 = { "irreversible" }
+
+; module flags
+
+!llvm.module.flags = !{!0, !1, !2, !3}
+
+!0 = !{i32 1, !"qir_major_version", i32 2}
+!1 = !{i32 7, !"qir_minor_version", i32 0}
+!2 = !{i32 1, !"dynamic_qubit_management", i1 false}
+!3 = !{i32 1, !"dynamic_result_management", i1 false}
\ No newline at end of file
diff --git a/pyqir/tests/random_bit.ll b/pyqir/tests/random_bit.ll
new file mode 100644
index 00000000..7c1cf384
--- /dev/null
+++ b/pyqir/tests/random_bit.ll
@@ -0,0 +1,34 @@
+; ModuleID = 'random_bit'
+%Result = type opaque
+%Qubit = type opaque
+
+define void @random_bit() #0 {
+block_0:
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 0 to %Qubit*))
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 1 to %Qubit*))
+  call void @__quantum__qis__cz__body(%Qubit* inttoptr (i64 1 to %Qubit*), %Qubit* inttoptr (i64 0 to %Qubit*))
+  call void @__quantum__qis__h__body(%Qubit* inttoptr (i64 1 to %Qubit*))
+  call void @__quantum__qis__m__body(%Qubit* inttoptr (i64 1 to %Qubit*), %Result* inttoptr (i64 0 to %Result*))
+  call void @__quantum__rt__result_record_output(%Result* inttoptr (i64 0 to %Result*), i8* null)
+  ret void
+}
+
+declare void @__quantum__qis__h__body(%Qubit*)
+
+declare void @__quantum__qis__cz__body(%Qubit*, %Qubit*)
+
+declare void @__quantum__rt__result_record_output(%Result*, i8*)
+
+declare void @__quantum__qis__m__body(%Qubit*, %Result*) #1
+
+attributes #0 = { "entry_point" "output_labeling_schema" "qir_profiles"="base_profile" "required_num_qubits"="2" "required_num_results"="1" }
+attributes #1 = { "irreversible" }
+
+; module flags
+
+!llvm.module.flags = !{!0, !1, !2, !3}
+
+!0 = !{i32 1, !"qir_major_version", i32 1}
+!1 = !{i32 7, !"qir_minor_version", i32 0}
+!2 = !{i32 1, !"dynamic_qubit_management", i1 false}
+!3 = !{i32 1, !"dynamic_result_management", i1 false}
\ No newline at end of file
diff --git a/pyqir/tests/test_module_linking.py b/pyqir/tests/test_module_linking.py
new file mode 100644
index 00000000..10c5d94c
--- /dev/null
+++ b/pyqir/tests/test_module_linking.py
@@ -0,0 +1,104 @@
+# Copyright (c) Microsoft Corporation.
+# Licensed under the MIT License.
+
+from pathlib import Path
+
+import pyqir
+import pytest
+
+current_file_path = Path(__file__)
+# Get the directory of the current file
+current_dir = current_file_path.parent
+
+from pyqir import (
+    Context,
+    Module,
+)
+
+
+def read_file(file_name: str) -> str:
+    return Path(current_dir / file_name).read_text(encoding="utf-8")
+
+
+def get_int_flag_value(module: Module, flag_name: str) -> int:
+    flag = module.get_flag(flag_name)
+    assert flag is not None
+    assert isinstance(flag, pyqir.ConstantAsMetadata)
+    assert isinstance(flag.value, pyqir.IntConstant)
+    return flag.value.value
+
+
+def test_link_modules_with_same_context() -> None:
+    context = Context()
+    ir = read_file("random_bit.ll")
+    dest = Module.from_ir(context, ir)
+    ir = read_file("5_bit_random_number.ll")
+    src = Module.from_ir(context, ir)
+    dest.link(src)
+    assert dest.verify() is None
+    actual_ir = str(dest)
+    expected_ir = str(read_file("combined_module.ll"))
+    assert actual_ir == expected_ir
+
+
+def test_link_modules_with_different_contexts() -> None:
+    ir = read_file("random_bit.ll")
+    dest = Module.from_ir(Context(), ir)
+    ir = read_file("5_bit_random_number.ll")
+    src = Module.from_ir(Context(), ir)
+    with pytest.raises(ValueError) as ex:
+        dest.link(src)
+    assert (
+        str(ex.value)
+        == "Cannot link modules from different contexts. Modules are untouched."
+    )
+
+
+def test_link_module_with_src_minor_version_less() -> None:
+    context = Context()
+    ir = read_file("profile_v1.0_compat.ll")
+    dest = Module.from_ir(context, ir)
+    ir = read_file("profile_v1.1_compat.ll")
+    src = Module.from_ir(context, ir)
+    dest.link(src)
+    assert get_int_flag_value(dest, "qir_minor_version") == 1
+
+
+def test_link_module_with_src_minor_version_greater() -> None:
+    context = Context()
+    ir = read_file("profile_v1.1_compat.ll")
+    dest = Module.from_ir(context, ir)
+    ir = read_file("profile_v1.0_compat.ll")
+    src = Module.from_ir(context, ir)
+    dest.link(src)
+    assert get_int_flag_value(dest, "qir_minor_version") == 1
+
+
+def test_link_module_with_src_major_version_less() -> None:
+    context = Context()
+    ir = read_file("profile_v2.0_compat.ll")
+    dest = Module.from_ir(context, ir)
+    ir = read_file("profile_v1.0_compat.ll")
+    src = Module.from_ir(context, ir)
+    with pytest.raises(ValueError) as ex:
+        dest.link(src)
+
+    assert (
+        "linking module flags 'qir_major_version': IDs have conflicting values"
+        in str(ex)
+    )
+
+
+def test_link_module_with_src_major_version_greater() -> None:
+    context = Context()
+    ir = read_file("profile_v1.0_compat.ll")
+    dest = Module.from_ir(context, ir)
+    ir = read_file("profile_v2.0_compat.ll")
+    src = Module.from_ir(context, ir)
+    with pytest.raises(ValueError) as ex:
+        dest.link(src)
+
+    assert (
+        "linking module flags 'qir_major_version': IDs have conflicting values"
+        in str(ex)
+    )